Compare commits
26
Commits
v1.0.0-rc3
...
bfe3861314
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe3861314 | ||
|
|
1379497897 | ||
|
|
b3b8408d18 | ||
|
|
9f1e9f5253 | ||
|
|
2c90144e7b | ||
|
|
26b5160aa8 | ||
|
|
33045a24b5 | ||
|
|
701ed9b94b | ||
|
|
233765033a | ||
|
|
d33316caf3 | ||
|
|
6de69f7349 | ||
|
|
93b13d6638 | ||
|
|
b73bbde46e | ||
|
|
ac850823f4 | ||
|
|
17376d885b | ||
|
|
87cb1ab5f6 | ||
|
|
77d55abb7e | ||
|
|
7b6712538e | ||
|
|
63690a9ec9 | ||
|
|
ac2bae0489 | ||
|
|
b49d0068df | ||
|
|
6b19e4c96c | ||
|
|
5f9d624d74 | ||
|
|
a19cca2f34 | ||
|
|
00d8ba26c5 | ||
|
|
385c0ca76f |
No files matched your search
@@ -18,4 +18,3 @@ jobs:
|
|||||||
# All history and tags, so git describe finds the version tag.
|
# All history and tags, so git describe finds the version tag.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
- run: script/docker-smoke
|
|
||||||
@@ -1,75 +1,76 @@
|
|||||||
# Repository Rules
|
# Repository Rules
|
||||||
|
|
||||||
Last Updated 2026-01-08
|
Last Updated 2026-10-27
|
||||||
|
|
||||||
These rules MUST be followed at all times, it is very important.
|
These rules MUST be followed at all times. This is very important.
|
||||||
|
|
||||||
- Never use `git add -A` - add specific changes to a deliberate commit. A commit
|
- Never use `git add -A`. Stage specific changes for a deliberate commit. A
|
||||||
should contain one change. After each change, make a commit with a good
|
commit holds one change. After each change, make a commit with a good one-line
|
||||||
one-line summary.
|
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 a test to exclude special cases or otherwise make it pass without
|
||||||
without asking first. In almost all cases, the code should be changed, NOT the
|
asking first. In almost all cases, change the code, NOT the test. You need
|
||||||
tests. If you think the test needs to be changed, make your case for that and
|
explicit user approval to modify an existing test. If you think a test needs
|
||||||
ask for permission to proceed, then stop. You need explicit user approval to
|
to change, make your case and ask for permission to proceed. Then stop. A bug
|
||||||
modify existing tests. A bug fix may change an existing test without asking
|
fix may change an existing test without asking only when that test pins
|
||||||
when that test pins exactly the behavior the filed bug calls wrong, and the
|
exactly the behavior the filed bug calls wrong. The change must then move the
|
||||||
change moves it to the fixed behavior and nothing else; any other change to an
|
test to the fixed behavior and nothing else. Any other change to an existing
|
||||||
existing test still needs explicit user approval. (You do not need user
|
test needs explicit user approval. Writing NEW tests needs no approval.
|
||||||
approval for writing NEW tests.)
|
|
||||||
|
|
||||||
- When linting, assume the linter config is CORRECT, and that each item output
|
- When linting, assume the linter config is CORRECT. Treat each item the linter
|
||||||
by the linter is something that legitimately needs fixing in the code.
|
reports as something that legitimately needs fixing in the 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` and
|
- Before committing, run `make check`. It runs `make test`, `make lint` and
|
||||||
`make check-fmt`. Any issues discovered MUST be resolved before committing
|
`make fmt-check`. Any issue it finds MUST be resolved before committing,
|
||||||
unless explicitly told otherwise.
|
unless you are explicitly told otherwise.
|
||||||
|
|
||||||
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate
|
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate
|
||||||
logging to the test to ensure it is written correctly. Commit that. Then go
|
logging to the test, to make sure the test is written correctly. Commit that.
|
||||||
about fixing the bug until the test passes (without modifying the test
|
Then work on the fix until the test passes, without changing the test further.
|
||||||
further). Then commit that.
|
Then commit the fix.
|
||||||
|
|
||||||
- When adding a new feature, do the same - implement a test first (TDD). It
|
- When adding a new feature, do the same: write a test first (test-driven
|
||||||
doesn't have to be super complex. Commit the test, then commit the feature.
|
development, TDD). The test does not need to be very complex. Commit the test,
|
||||||
|
then commit the feature.
|
||||||
|
|
||||||
- When adding a new feature, use a feature branch. When the feature is
|
- When adding a new feature, use a feature branch. The feature branch reaches
|
||||||
completely finished and the code is up to standards (passes `make check`) then
|
`next` through a PR that passes review. It is merged only when the feature is
|
||||||
and only then can the feature branch be merged into `main` and the branch
|
completely finished and the code is up to standards. The code is up to
|
||||||
deleted.
|
standards when it passes `make check`. The branch is deleted after the merge.
|
||||||
|
Only the user merges `next` into `main`.
|
||||||
|
|
||||||
- Write godoc documentation comments for all exported types and functions as you
|
- Write `godoc` comments for all exported types and functions as you go along.
|
||||||
go along.
|
|
||||||
|
|
||||||
- ALWAYS be consistent in naming. If you name something one thing in one place,
|
- ALWAYS be consistent in naming. If you name something one way in one place,
|
||||||
name it the EXACT SAME THING in another place.
|
use the EXACT SAME name in every other place.
|
||||||
|
|
||||||
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
|
- Be descriptive and specific in naming. `al` is bad; `SourceHostAllowlist` is
|
||||||
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
||||||
|
|
||||||
- This is not prototype or teaching code - this is designed for production. Any
|
- This is not prototype or teaching code. It is designed for production. Any
|
||||||
security issues (such as denial of service) or other web vulnerabilities are
|
security issue (such as denial of service) or other web vulnerability is a P1
|
||||||
P1 bugs and must be added to TODO.md at the top.
|
bug. It must be added at the top of `TODO.md`.
|
||||||
|
|
||||||
- As this is production code, no stubbing of implementations unless specifically
|
- Do not stub an implementation unless you are specifically instructed to. This
|
||||||
instructed. We need working implementations.
|
is production code, and 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 encoding
|
explicitly specifies a value. For example, if a user requests `format=webp`
|
||||||
is not supported, return an error - do NOT silently output PNG instead. If a
|
and WebP encoding is not supported, return an error. Do NOT silently output
|
||||||
user specifies fit=invalid and that fit mode doesn't exist, return an error -
|
PNG instead. If a user specifies `fit=invalid` and that fit mode does not
|
||||||
do NOT silently default to "cover". Silent fallbacks violate the principle of
|
exist, return an error. Do NOT silently default to `cover`. A silent fallback
|
||||||
least surprise and mask bugs. The only acceptable defaults are for OMITTED
|
violates the principle of least surprise and masks bugs. Defaults are
|
||||||
parameters, never for INVALID explicit values.
|
acceptable only for OMITTED parameters, never for INVALID explicit values.
|
||||||
|
|
||||||
- Avoid vendoring deps unless specifically instructed to. NEVER commit the
|
- Avoid vendoring dependencies unless specifically instructed to. NEVER commit
|
||||||
vendor directory, NEVER commit compiled binaries. If these directories or
|
the `vendor/` directory. NEVER commit compiled binaries. If such a directory
|
||||||
files exist, add them to .gitignore (and commit the .gitignore) if they are
|
or file exists and `.gitignore` does not list it, add it to `.gitignore`.
|
||||||
not already in there. Keep the entire git repository (with history) small -
|
Commit that `.gitignore` change. Keep the entire git repository, history
|
||||||
under 20MiB, unless you specifically must commit larger files (e.g. test
|
included, under 20 MiB. Go over 20 MiB only when you specifically must commit
|
||||||
fixture example media files). Only OUR source code and immediately supporting
|
a large file, such as a media file used as a test fixture. Only OUR source
|
||||||
files (such as test examples) goes into the repo/history.
|
code and its immediately supporting files, such as test examples, go into the
|
||||||
|
repository and its history.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest bench
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
@@ -64,6 +64,12 @@ docker-smoke:
|
|||||||
loadtest:
|
loadtest:
|
||||||
@script/loadtest
|
@script/loadtest
|
||||||
|
|
||||||
|
# Serve cache hits in parallel through the image handler and report hits per
|
||||||
|
# second (needs Go on the host after `script/bootstrap --cgo`, not Docker; a
|
||||||
|
# benchmark, not part of check)
|
||||||
|
bench:
|
||||||
|
@script/bench
|
||||||
|
|
||||||
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
||||||
docker-versioned:
|
docker-versioned:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ require (
|
|||||||
github.com/go-chi/httprate v0.16.0
|
github.com/go-chi/httprate v0.16.0
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
|||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
|||||||
@@ -72,13 +72,3 @@ func (w *HostAllowList) IsAllowed(u *url.URL) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty returns true if the allow list has no entries.
|
|
||||||
func (w *HostAllowList) IsEmpty() bool {
|
|
||||||
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count returns the total number of allow list entries.
|
|
||||||
func (w *HostAllowList) Count() int {
|
|
||||||
return len(w.exactHosts) + len(w.suffixHosts)
|
|
||||||
}
|
|
||||||
@@ -139,87 +139,3 @@ func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostAllowList_IsEmpty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
patterns []string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
patterns: []string{},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil",
|
|
||||||
patterns: nil,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace only",
|
|
||||||
patterns: []string{" ", ""},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "has entries",
|
|
||||||
patterns: []string{"example.com"},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
w := allowlist.New(tt.patterns)
|
|
||||||
if got := w.IsEmpty(); got != tt.want {
|
|
||||||
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostAllowList_Count(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
patterns []string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
patterns: []string{},
|
|
||||||
want: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact hosts only",
|
|
||||||
patterns: []string{"a.com", "b.com", "c.com"},
|
|
||||||
want: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix hosts only",
|
|
||||||
patterns: []string{".a.com", ".b.com"},
|
|
||||||
want: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed",
|
|
||||||
patterns: []string{"exact.com", ".suffix.com"},
|
|
||||||
want: 2,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
w := allowlist.New(tt.patterns)
|
|
||||||
if got := w.Count(); got != tt.want {
|
|
||||||
t.Errorf("Count() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/middleware"
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/providers"
|
||||||
"sneak.berlin/go/pixa/internal/server"
|
"sneak.berlin/go/pixa/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,7 +63,10 @@ func run(_ *cobra.Command, _ []string) {
|
|||||||
server.New,
|
server.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
// The registry /metrics serves
|
||||||
|
func() prometheus.Registerer { return prometheus.DefaultRegisterer },
|
||||||
),
|
),
|
||||||
|
fx.Provide(providers.Constructors()...),
|
||||||
fx.Invoke(
|
fx.Invoke(
|
||||||
func(log *logger.Logger) { log.Identify() },
|
func(log *logger.Logger) { log.Identify() },
|
||||||
func(*server.Server) {},
|
func(*server.Server) {},
|
||||||
|
|||||||
@@ -135,10 +135,38 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultCacheMaxBytesAppliesFloor verifies that when 75% of the
|
||||||
|
// free space plus what the cache holds is below 500 MiB, the default is
|
||||||
|
// floored at DefaultCacheMaxBytesFloor.
|
||||||
|
func TestDefaultCacheMaxBytesAppliesFloor(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()
|
||||||
|
|
||||||
|
got := DefaultCacheMaxBytes(tc.freeBytes, 0)
|
||||||
|
if got != DefaultCacheMaxBytesFloor {
|
||||||
|
t.Errorf("DefaultCacheMaxBytes = %d, want floor %d",
|
||||||
|
got, DefaultCacheMaxBytesFloor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
||||||
// cache_max_bytes is recorded as not explicit, so the cache works out
|
// cache_max_bytes is recorded as not explicit, so pixad works out the
|
||||||
// the default when it opens, and that an explicit zero is recorded as
|
// default when it builds the cache, and that an explicit zero is recorded
|
||||||
// explicit, so it disables the disk cache instead.
|
// as explicit, so it disables the disk cache instead.
|
||||||
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+377
-591
File diff suppressed because it is too large.
Load diff
@@ -1,97 +1,12 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
||||||
// with a real signing key, as Getting Started in README.md has it copied
|
// with a real signing key, as Getting Started in README.md has it copied
|
||||||
// and edited, and checks that it allowlists no host: every image on an
|
// and edited, and checks that it allowlists no host: every image on an
|
||||||
|
|||||||
@@ -15,8 +15,36 @@ import (
|
|||||||
_ "modernc.org/sqlite" // SQLite driver registration
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The config keys the tests name in the errors they expect.
|
||||||
|
const (
|
||||||
|
keyDebug = "debug"
|
||||||
|
keyMaintenanceMode = "maintenance_mode"
|
||||||
|
keyPort = "port"
|
||||||
|
keyStateDir = "state_dir"
|
||||||
|
keySentryDSN = "sentry_dsn"
|
||||||
|
keyDBURL = "db_url"
|
||||||
|
keyMetrics = "metrics"
|
||||||
|
keyMetricsUsername = "metrics.username"
|
||||||
|
keyMetricsPassword = "metrics.password"
|
||||||
|
keySigningKey = "signing_key"
|
||||||
|
keyAllowlistHosts = "allowlist_hosts"
|
||||||
|
keyRefererBlocklist = "referer_blocklist"
|
||||||
|
keyAllowHTTP = "allow_http"
|
||||||
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
|
keyUpstreamConnections = "upstream_connections"
|
||||||
|
keyMaxConcurrentProcessing = "max_concurrent_processing"
|
||||||
|
keyCacheMaxBytes = "cache_max_bytes"
|
||||||
|
keyBlockedNetworks = "blocked_networks"
|
||||||
|
keyTrustedProxies = "trusted_proxies"
|
||||||
|
keyAccessControlAllowOrigin = "access_control_allow_origin"
|
||||||
|
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||||
|
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||||
|
keyDownstreamTimeout = "downstream_timeout"
|
||||||
|
keyImageRequestsPerMinute = "image_requests_per_minute"
|
||||||
|
)
|
||||||
|
|
||||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
// minimum length requirement in validate().
|
// minimum length requirement in validateSigningKey.
|
||||||
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
// signingKeyLine is a valid signing_key config line used as the base of
|
// signingKeyLine is a valid signing_key config line used as the base of
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReadmeNamesEverySetting checks that the list of settings in
|
||||||
|
// README.md names exactly the keys of the settings table, one item each.
|
||||||
|
// The list is the first one in the Configuration section, and it ends at
|
||||||
|
// the first blank line. Each item starts with its key in backticks.
|
||||||
|
func TestReadmeNamesEverySetting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
readme, err := os.ReadFile(filepath.Join("..", "..", "README.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read README.md: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, section, found := strings.Cut(string(readme), "\n### Configuration\n")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("README.md has no Configuration section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The section ends at the next heading of its level or above.
|
||||||
|
section, _, _ = strings.Cut(section, "\n## ")
|
||||||
|
section, _, _ = strings.Cut(section, "\n### ")
|
||||||
|
|
||||||
|
_, list, found := strings.Cut(section, "\n- ")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("README.md's Configuration section has no list")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, _, _ = strings.Cut("- "+list, "\n\n")
|
||||||
|
|
||||||
|
itemKey := regexp.MustCompile("^- `([^`]+)`")
|
||||||
|
named := map[string]bool{}
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(list, "\n") {
|
||||||
|
// A line that does not start with "- " goes on with the item above.
|
||||||
|
if !strings.HasPrefix(line, "- ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
match := itemKey.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
t.Errorf("README.md's list of settings has an item "+
|
||||||
|
"that does not start with a key in backticks: %s", line)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if named[match[1]] {
|
||||||
|
t.Errorf("README.md's list of settings names %s twice", match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
named[match[1]] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range new(Config).settings() {
|
||||||
|
if !named[s.key] {
|
||||||
|
t.Errorf("README.md's list of settings does not name %s", s.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(named, s.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range named {
|
||||||
|
t.Errorf("README.md's list of settings names %s, "+
|
||||||
|
"which is not a setting", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExampleConfigNamesEverySetting checks that
|
||||||
|
// configs/config.example.yml names exactly the keys of the settings
|
||||||
|
// table, each one set or commented out with "# ". A key line holds the
|
||||||
|
// key, a colon and at most a one-word value, which tells it from a
|
||||||
|
// comment line that starts with a word and a colon. An indented key is
|
||||||
|
// inside the map of the key above it, as username is inside metrics.
|
||||||
|
func TestExampleConfigNamesEverySetting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const examplePath = "configs/config.example.yml"
|
||||||
|
|
||||||
|
example, err := os.ReadFile(filepath.Join("..", "..", examplePath))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read %s: %v", examplePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyLine := regexp.MustCompile(`(?m)^(?:# )?( *)([a-z_]+):(?: \S+)?$`)
|
||||||
|
named := map[string]bool{}
|
||||||
|
|
||||||
|
var mapKey string
|
||||||
|
|
||||||
|
for _, match := range keyLine.FindAllStringSubmatch(string(example), -1) {
|
||||||
|
indent, key := match[1], match[2]
|
||||||
|
if indent == "" {
|
||||||
|
mapKey = key
|
||||||
|
named[key] = true
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key holding the map is not itself a setting.
|
||||||
|
delete(named, mapKey)
|
||||||
|
|
||||||
|
named[mapKey+"."+key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The env section sets environment variables; it is not a setting.
|
||||||
|
delete(named, "env")
|
||||||
|
|
||||||
|
for _, s := range new(Config).settings() {
|
||||||
|
if !named[s.key] {
|
||||||
|
t.Errorf("%s does not name %s", examplePath, s.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(named, s.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range named {
|
||||||
|
t.Errorf("%s names %s, which is not a setting", examplePath, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,7 +138,8 @@ func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
|||||||
|
|
||||||
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
||||||
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
||||||
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the
|
||||||
|
// settings table.
|
||||||
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||||
// A config file's env section loaded by another test can leave a
|
// 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
|
// PIXA_ variable set for the whole process, so every one is unset
|
||||||
@@ -159,8 +160,8 @@ func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
|||||||
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
||||||
|
|
||||||
for _, name := range envVarNames() {
|
for _, s := range new(Config).settings() {
|
||||||
t.Setenv(name, "")
|
t.Setenv(s.envVar, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := validateKnownEnvVars()
|
err := validateKnownEnvVars()
|
||||||
|
|||||||
@@ -104,40 +104,31 @@ func writeConcurrently(t *testing.T, db *sql.DB) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
var hits, sources int
|
var sources int
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM source_content`).
|
||||||
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
|
Scan(&sources)
|
||||||
FROM cache_stats WHERE id = 1
|
|
||||||
`).Scan(&hits, &sources)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to count the writes: %v", err)
|
t.Fatalf("failed to count the writes: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hits != totalRequests || sources != totalRequests {
|
if sources != totalRequests {
|
||||||
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
|
t.Errorf("%d source_content rows, want %d", sources, totalRequests)
|
||||||
hits, sources, totalRequests)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLikeOneRequest makes the writes one request and the eviction pass
|
// writeLikeOneRequest makes the writes one request and the eviction pass
|
||||||
// make: it counts a cache hit, stores a source, records a transformed image
|
// make: it stores a source, records a transformed image and deletes that
|
||||||
// and deletes that record again.
|
// record again.
|
||||||
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
|
(source_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("storing a source: %w", err)
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
||||||
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
|
(cache_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("recording a transformed image: %w", err)
|
return fmt.Errorf("recording a transformed image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ func ParseMigrationVersion(filename string) (int, error) {
|
|||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Database instance.
|
// New opens the database and applies the migrations, so that the parts
|
||||||
|
// fx builds after it can read the database while they are built. Its
|
||||||
|
// stop hook closes the database. fx runs stop hooks in the reverse of the
|
||||||
|
// order it built their parts in, so the stop hooks of those parts run
|
||||||
|
// before this one.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||||
s := &Database{
|
s := &Database{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
@@ -91,20 +95,16 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
|
|
||||||
s.log.Info("Database instantiated")
|
s.log.Info("Database instantiated")
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
err := s.connect(context.Background())
|
||||||
OnStart: func(ctx context.Context) error {
|
if err != nil {
|
||||||
s.log.Info("Database OnStart Hook")
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return s.connect(ctx)
|
lc.Append(fx.Hook{
|
||||||
},
|
|
||||||
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 {
|
return s.db.Close()
|
||||||
return s.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the application tables created by 001.sql exist.
|
// Verify that the application tables created by 001.sql exist.
|
||||||
tables := []string{
|
tables := []string{"source_content", "negative_cache"}
|
||||||
"source_content", "source_metadata", "output_content",
|
|
||||||
"request_cache", "negative_cache", "cache_stats",
|
|
||||||
}
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,51 @@
|
|||||||
-- Migration 001: Initial schema
|
-- Migration 001: Initial schema
|
||||||
-- Creates all tables for the pixa caching image proxy
|
-- Creates all tables for the pixa caching image proxy
|
||||||
|
|
||||||
-- Source content blobs
|
-- Source images, one row per source URL
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
||||||
|
-- is the SHA-256 of the source's host, path and query.
|
||||||
|
-- fetched_at is when the cached bytes were fetched. checked_at is when
|
||||||
|
-- upstream last sent them or answered that they had not changed.
|
||||||
|
-- upstream_etag and upstream_last_modified are the ETag and Last-Modified
|
||||||
|
-- headers upstream sent with the bytes, or '' when it sent none.
|
||||||
CREATE TABLE IF NOT EXISTS source_content (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
upstream_etag TEXT NOT NULL DEFAULT '',
|
||||||
|
upstream_last_modified TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
ON source_content(last_accessed_at);
|
ON source_content(last_accessed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_source_content_checked
|
||||||
-- Source URL metadata - maps URLs to content hashes
|
ON source_content(checked_at);
|
||||||
-- JSON stored at: cache/metadata/<hostname>/<path_hash>.json
|
|
||||||
CREATE TABLE IF NOT EXISTS source_metadata (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_host TEXT NOT NULL,
|
|
||||||
source_path TEXT NOT NULL,
|
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
|
||||||
path_hash TEXT NOT NULL,
|
|
||||||
content_hash TEXT,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
content_type TEXT,
|
|
||||||
response_headers TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME,
|
|
||||||
etag TEXT,
|
|
||||||
last_modified TEXT,
|
|
||||||
UNIQUE(source_host, source_path, source_query),
|
|
||||||
FOREIGN KEY (content_hash) REFERENCES source_content(content_hash)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_host ON source_metadata(source_host);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_path_hash ON source_metadata(path_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
|
||||||
|
|
||||||
-- Processed variant blobs
|
-- Processed variant blobs
|
||||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key>. Tracked here
|
||||||
-- sidecar with the content type). Tracked here (like source content
|
-- (like source content blobs above) so total cache usage is known
|
||||||
-- blobs above) so total cache usage is known without a directory scan,
|
-- without a directory scan, and so LRU eviction has a timestamp to order
|
||||||
-- and so LRU eviction has a timestamp to order on.
|
-- on. A cache hit does not read this table: it takes the content type
|
||||||
|
-- from the file's first bytes, and its last_accessed_at is written
|
||||||
|
-- later, in a batch. checked_at is the checked_at of the source the
|
||||||
|
-- variant was made from, when it was made or last renewed; the variant
|
||||||
|
-- file's modification time holds the same time for cache hits.
|
||||||
|
-- upstream_etag and upstream_last_modified are those of the source bytes
|
||||||
|
-- the variant was made from, so the variant can still be checked with
|
||||||
|
-- upstream after its source is evicted.
|
||||||
CREATE TABLE IF NOT EXISTS variant_content (
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
cache_key TEXT PRIMARY KEY,
|
cache_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
content_type TEXT NOT NULL,
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
upstream_etag TEXT NOT NULL DEFAULT '',
|
||||||
|
upstream_last_modified TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
ON variant_content(last_accessed_at);
|
ON variant_content(last_accessed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_variant_content_checked
|
||||||
|
ON variant_content(checked_at);
|
||||||
|
|
||||||
-- Total cache usage: the sum of size_bytes over source_content and
|
-- Total cache usage: the sum of size_bytes over source_content and
|
||||||
-- variant_content, kept by the triggers below in the same statement
|
-- variant_content, kept by the triggers below in the same statement
|
||||||
@@ -120,63 +115,19 @@ BEGIN
|
|||||||
WHERE id = 1;
|
WHERE id = 1;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
-- Output/transformed content blobs
|
-- Negative cache: source URLs whose fetch failed with an upstream error
|
||||||
-- Not written: transformed images are stored in cache/variants and
|
-- status, or whose first bytes did not match their Content-Type. pixa does
|
||||||
-- tracked in variant_content above.
|
-- not fetch such a URL again until its row expires.
|
||||||
CREATE TABLE IF NOT EXISTS output_content (
|
|
||||||
content_hash TEXT PRIMARY KEY,
|
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Request cache - maps full request params to output content
|
|
||||||
CREATE TABLE IF NOT EXISTS request_cache (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
cache_key TEXT NOT NULL UNIQUE,
|
|
||||||
source_metadata_id INTEGER NOT NULL,
|
|
||||||
output_hash TEXT NOT NULL,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
format TEXT NOT NULL,
|
|
||||||
quality INTEGER NOT NULL DEFAULT 85,
|
|
||||||
fit_mode TEXT NOT NULL DEFAULT 'cover',
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
access_count INTEGER NOT NULL DEFAULT 1,
|
|
||||||
FOREIGN KEY (source_metadata_id) REFERENCES source_metadata(id),
|
|
||||||
FOREIGN KEY (output_hash) REFERENCES output_content(content_hash)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_key ON request_cache(cache_key);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_source ON request_cache(source_metadata_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_output ON request_cache(output_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_cache_fetched ON request_cache(fetched_at);
|
|
||||||
|
|
||||||
-- Negative cache for failed fetches (404s, timeouts, etc.)
|
|
||||||
CREATE TABLE IF NOT EXISTS negative_cache (
|
CREATE TABLE IF NOT EXISTS negative_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
source_query TEXT NOT NULL DEFAULT '',
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
UNIQUE(source_host, source_path, source_query)
|
UNIQUE(source_host, source_path, source_query)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_negative_cache_expires ON negative_cache(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_negative_cache_expires ON negative_cache(expires_at);
|
||||||
|
|
||||||
-- Cache statistics for monitoring
|
|
||||||
CREATE TABLE IF NOT EXISTS cache_stats (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
hit_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
miss_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
upstream_fetch_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
upstream_fetch_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
transform_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
|
|
||||||
|
|
||||||
-- A random value that a login session carries from its login. pixa makes it
|
-- A random value that a login session carries from its login. pixa makes it
|
||||||
-- when the table has no row, and each logout replaces it; a login session
|
-- when the table has no row, and each logout replaces it; a login session
|
||||||
-- carrying another value is refused, so logging out ends every login session.
|
-- carrying another value is refused, so logging out ends every login session.
|
||||||
|
|||||||
+62
-82
@@ -7,18 +7,15 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values for optional fields.
|
// Default values for optional fields.
|
||||||
const (
|
const (
|
||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imgcache.FormatJXL
|
DefaultFormat = imagerequest.FormatJXL
|
||||||
DefaultFitMode = imgcache.FitCover
|
DefaultFitMode = imagerequest.FitCover
|
||||||
|
|
||||||
// HKDF salt for URL encryption key derivation
|
|
||||||
urlKeySalt = "pixa-urlenc-v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Errors returned by encurl operations.
|
// Errors returned by encurl operations.
|
||||||
@@ -28,19 +25,19 @@ var (
|
|||||||
ErrDecryptFailed = errors.New("failed to decrypt URL")
|
ErrDecryptFailed = errors.New("failed to decrypt URL")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Payload contains all image request parameters in a compact format.
|
// payload contains all image request parameters in a compact format.
|
||||||
// Field names are short to minimize encoded size.
|
// Field names are short to minimize encoded size.
|
||||||
// Fields with omitempty use sane defaults when absent.
|
// Fields with omitempty use sane defaults when absent.
|
||||||
type Payload struct {
|
type payload struct {
|
||||||
SourceHost string `cbor:"h"` // required
|
SourceHost string `cbor:"h"` // required
|
||||||
SourcePath string `cbor:"p"` // required
|
SourcePath string `cbor:"p"` // required
|
||||||
SourceQuery string `cbor:"q,omitempty"` // optional
|
SourceQuery string `cbor:"q,omitempty"` // optional
|
||||||
Width int `cbor:"w,omitempty"` // 0 = original
|
Width int `cbor:"w,omitempty"` // 0 = original
|
||||||
Height int `cbor:"ht,omitempty"` // 0 = original
|
Height int `cbor:"ht,omitempty"` // 0 = original
|
||||||
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl
|
Format imagerequest.Format `cbor:"f,omitempty"` // default: jxl
|
||||||
Quality int `cbor:"ql,omitempty"` // default: 85
|
Quality int `cbor:"ql,omitempty"` // default: 85
|
||||||
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover
|
FitMode imagerequest.Fit `cbor:"fm,omitempty"` // default: cover
|
||||||
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generator creates and parses encrypted URL tokens.
|
// Generator creates and parses encrypted URL tokens.
|
||||||
@@ -48,20 +45,35 @@ type Generator struct {
|
|||||||
key [seal.KeySize]byte
|
key [seal.KeySize]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGenerator creates an encrypted URL generator with a key derived
|
// NewGenerator creates an encrypted URL generator that encrypts its tokens
|
||||||
// from the signing key.
|
// with key.
|
||||||
func NewGenerator(signingKey string) (*Generator, error) {
|
func NewGenerator(key [seal.KeySize]byte) *Generator {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
return &Generator{key: key}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Generator{key: key}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates an encrypted URL token from the payload.
|
// Generate creates an encrypted URL token from the request's source and
|
||||||
// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded.
|
// transform, each as the request holds it, and from expires; an empty format,
|
||||||
func (g *Generator) Generate(p *Payload) (string, error) {
|
// a quality of 0 or an empty fit mode is left out of the token, and a zero
|
||||||
|
// expires makes a token that never expires. The token is CBOR-encoded,
|
||||||
|
// encrypted with NaCl secretbox, and base64url-encoded.
|
||||||
|
func (g *Generator) Generate(
|
||||||
|
req *imagerequest.Request, expires time.Time,
|
||||||
|
) (string, error) {
|
||||||
|
p := &payload{
|
||||||
|
SourceHost: req.Source.Host,
|
||||||
|
SourcePath: req.Source.Path,
|
||||||
|
SourceQuery: req.Source.Query,
|
||||||
|
Width: req.Transform.Size.Width,
|
||||||
|
Height: req.Transform.Size.Height,
|
||||||
|
Format: req.Transform.Format,
|
||||||
|
Quality: req.Transform.Quality,
|
||||||
|
FitMode: req.Transform.FitMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !expires.IsZero() {
|
||||||
|
p.ExpiresAt = expires.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
// CBOR encode the payload
|
// CBOR encode the payload
|
||||||
data, err := cbor.Marshal(p)
|
data, err := cbor.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,40 +84,35 @@ func (g *Generator) Generate(p *Payload) (string, error) {
|
|||||||
return seal.Encrypt(g.key, data)
|
return seal.Encrypt(g.key, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse decrypts and validates an encrypted URL token.
|
// Parse decrypts and validates an encrypted URL token and returns the
|
||||||
|
// request it holds, with default values for omitted optional fields, and its
|
||||||
|
// expiry. A token that never expires gives the zero time.
|
||||||
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
||||||
func (g *Generator) Parse(token string) (*Payload, error) {
|
func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, 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) ||
|
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
||||||
errors.Is(err, seal.ErrInvalidPayload) {
|
errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
return nil, ErrDecryptFailed
|
return nil, time.Time{}, ErrDecryptFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
var p Payload
|
var p payload
|
||||||
|
|
||||||
err = cbor.Unmarshal(data, &p)
|
err = cbor.Unmarshal(data, &p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidFormat
|
return nil, time.Time{}, ErrInvalidFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check expiration (0 = never expires)
|
// Check expiration (0 = never expires)
|
||||||
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
|
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
|
||||||
return nil, ErrExpired
|
return nil, time.Time{}, ErrExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToImageRequest converts the payload to an ImageRequest.
|
|
||||||
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
|
||||||
// that never expires, gives the zero Expires.
|
|
||||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
format = DefaultFormat
|
format = DefaultFormat
|
||||||
@@ -126,44 +133,17 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
expires = time.Unix(p.ExpiresAt, 0)
|
expires = time.Unix(p.ExpiresAt, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imagerequest.Request{
|
||||||
SourceHost: p.SourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: p.SourcePath,
|
Host: p.SourceHost,
|
||||||
SourceQuery: p.SourceQuery,
|
Path: p.SourcePath,
|
||||||
Size: imgcache.Size{
|
Query: p.SourceQuery,
|
||||||
Width: p.Width,
|
|
||||||
Height: p.Height,
|
|
||||||
},
|
},
|
||||||
Format: format,
|
Transform: imagerequest.Transform{
|
||||||
Quality: quality,
|
Size: imagerequest.Size{Width: p.Width, Height: p.Height},
|
||||||
FitMode: fitMode,
|
Format: format,
|
||||||
Expires: expires,
|
Quality: quality,
|
||||||
}
|
FitMode: fitMode,
|
||||||
}
|
},
|
||||||
|
}, expires, nil
|
||||||
// FromImageRequest creates a Payload from an ImageRequest with the given expiration.
|
|
||||||
func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload {
|
|
||||||
p := &Payload{
|
|
||||||
SourceHost: req.SourceHost,
|
|
||||||
SourcePath: req.SourcePath,
|
|
||||||
SourceQuery: req.SourceQuery,
|
|
||||||
Width: req.Size.Width,
|
|
||||||
Height: req.Size.Height,
|
|
||||||
ExpiresAt: expiresAt.Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set non-default values to benefit from omitempty
|
|
||||||
if req.Format != "" && req.Format != DefaultFormat {
|
|
||||||
p.Format = req.Format
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Quality != 0 && req.Quality != DefaultQuality {
|
|
||||||
p.Quality = req.Quality
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode != "" && req.FitMode != DefaultFitMode {
|
|
||||||
p.FitMode = req.FitMode
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
}
|
||||||
+110
-188
@@ -6,7 +6,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared test fixture strings.
|
// Shared test fixture strings.
|
||||||
@@ -19,24 +20,29 @@ const (
|
|||||||
func TestGenerator_GenerateAndParse(t *testing.T) {
|
func TestGenerator_GenerateAndParse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, err := encurl.NewGenerator("test-signing-key-12345")
|
keys, err := seal.DeriveKeys("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
SourceQuery: testSourceQuery,
|
|
||||||
Width: 800,
|
|
||||||
Height: 600,
|
|
||||||
Format: imgcache.FormatWebP,
|
|
||||||
Quality: 90,
|
|
||||||
FitMode: imgcache.FitContain,
|
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := gen.Generate(payload)
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: testSourceHost,
|
||||||
|
Path: testSourcePath,
|
||||||
|
Query: testSourceQuery,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
|
Format: imagerequest.FormatWebP,
|
||||||
|
Quality: 90,
|
||||||
|
FitMode: imagerequest.FitContain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
token, err := gen.Generate(req, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -45,66 +51,73 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
t.Fatal("Generate() returned empty token")
|
t.Fatal("Generate() returned empty token")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := gen.Parse(token)
|
parsed, parsedExpires, err := gen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse() error = %v", err)
|
t.Fatalf("Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all fields
|
// Verify all fields
|
||||||
if parsed.SourceHost != payload.SourceHost {
|
if parsed.Source.Host != req.Source.Host {
|
||||||
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", parsed.Source.Host, req.Source.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourcePath != payload.SourcePath {
|
if parsed.Source.Path != req.Source.Path {
|
||||||
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", parsed.Source.Path, req.Source.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourceQuery != payload.SourceQuery {
|
if parsed.Source.Query != req.Source.Query {
|
||||||
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", parsed.Source.Query, req.Source.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Width != payload.Width {
|
if parsed.Transform.Size.Width != req.Transform.Size.Width {
|
||||||
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
t.Errorf("Width = %d, want %d",
|
||||||
|
parsed.Transform.Size.Width, req.Transform.Size.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Height != payload.Height {
|
if parsed.Transform.Size.Height != req.Transform.Size.Height {
|
||||||
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
t.Errorf("Height = %d, want %d",
|
||||||
|
parsed.Transform.Size.Height, req.Transform.Size.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Format != payload.Format {
|
if parsed.Transform.Format != req.Transform.Format {
|
||||||
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
t.Errorf("Format = %q, want %q",
|
||||||
|
parsed.Transform.Format, req.Transform.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Quality != payload.Quality {
|
if parsed.Transform.Quality != req.Transform.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d",
|
||||||
|
parsed.Transform.Quality, req.Transform.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.FitMode != payload.FitMode {
|
if parsed.Transform.FitMode != req.Transform.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q",
|
||||||
|
parsed.Transform.FitMode, req.Transform.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.ExpiresAt != payload.ExpiresAt {
|
if parsedExpires.Unix() != expires.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
t.Errorf("ExpiresAt = %d, want %d", parsedExpires.Unix(), expires.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_Expired(t *testing.T) {
|
func TestGenerator_Parse_Expired(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := gen.Generate(payload)
|
token, err := gen.Generate(req, time.Now().Add(-time.Hour)) // Already expired
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = gen.Parse(token)
|
_, _, err = gen.Parse(token)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail for expired token")
|
t.Error("Parse() should fail for expired token")
|
||||||
}
|
}
|
||||||
@@ -117,9 +130,10 @@ func TestGenerator_Parse_Expired(t *testing.T) {
|
|||||||
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
_, err := gen.Parse("not-a-valid-token")
|
_, _, err := gen.Parse("not-a-valid-token")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail for invalid token")
|
t.Error("Parse() should fail for invalid token")
|
||||||
}
|
}
|
||||||
@@ -128,15 +142,17 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
|||||||
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(payload)
|
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// Tamper with the token
|
// Tamper with the token
|
||||||
tampered := []byte(token)
|
tampered := []byte(token)
|
||||||
@@ -144,7 +160,7 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
tampered[10] ^= 0x01
|
tampered[10] ^= 0x01
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := gen.Parse(string(tampered))
|
_, _, err := gen.Parse(string(tampered))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail for tampered token")
|
t.Error("Parse() should fail for tampered token")
|
||||||
}
|
}
|
||||||
@@ -153,172 +169,78 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen1, _ := encurl.NewGenerator("signing-key-1")
|
keys1, _ := seal.DeriveKeys("signing-key-1")
|
||||||
gen2, _ := encurl.NewGenerator("signing-key-2")
|
keys2, _ := seal.DeriveKeys("signing-key-2")
|
||||||
|
gen1 := encurl.NewGenerator(keys1.EncryptedURLKey)
|
||||||
|
gen2 := encurl.NewGenerator(keys2.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen1.Generate(payload)
|
token, _ := gen1.Generate(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
_, err := gen2.Parse(token)
|
_, _, err := gen2.Parse(token)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse() should fail with different signing key")
|
t.Error("Parse() should fail with different signing key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest(t *testing.T) {
|
func TestGenerator_Parse_Defaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
SourceHost: testSourceHost,
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
SourcePath: testSourcePath,
|
|
||||||
SourceQuery: testSourceQuery,
|
// Request with only required fields - should get defaults
|
||||||
Width: 800,
|
token, err := gen.Generate(&imagerequest.Request{
|
||||||
Height: 600,
|
Source: imagerequest.Source{
|
||||||
Format: imgcache.FormatWebP,
|
Host: testSourceHost,
|
||||||
Quality: 90,
|
Path: testSourcePath,
|
||||||
FitMode: imgcache.FitContain,
|
},
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
}, time.Now().Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
req, _, err := gen.Parse(token)
|
||||||
|
if err != nil {
|
||||||
if req.SourceHost != payload.SourceHost {
|
t.Fatalf("Parse() error = %v", err)
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != payload.SourcePath {
|
if req.Transform.Format != encurl.DefaultFormat {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
t.Errorf("Format = %q, want default %q",
|
||||||
|
req.Transform.Format, encurl.DefaultFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != payload.SourceQuery {
|
if req.Transform.Quality != encurl.DefaultQuality {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
t.Errorf("Quality = %d, want default %d",
|
||||||
|
req.Transform.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Width != payload.Width {
|
if req.Transform.FitMode != encurl.DefaultFitMode {
|
||||||
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
t.Errorf("FitMode = %q, want default %q",
|
||||||
}
|
req.Transform.FitMode, encurl.DefaultFitMode)
|
||||||
|
|
||||||
if req.Size.Height != payload.Height {
|
|
||||||
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Format != payload.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Quality != payload.Quality {
|
|
||||||
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode != payload.FitMode {
|
|
||||||
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Payload with only required fields - should get defaults
|
|
||||||
payload := &encurl.Payload{
|
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
|
||||||
|
|
||||||
if req.Format != encurl.DefaultFormat {
|
|
||||||
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Quality != encurl.DefaultQuality {
|
|
||||||
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FitMode != encurl.DefaultFitMode {
|
|
||||||
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromImageRequest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := &imgcache.ImageRequest{
|
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
SourceQuery: testSourceQuery,
|
|
||||||
Size: imgcache.Size{Width: 800, Height: 600},
|
|
||||||
Format: imgcache.FormatWebP,
|
|
||||||
Quality: 90,
|
|
||||||
FitMode: imgcache.FitContain,
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Hour)
|
|
||||||
payload := encurl.FromImageRequest(req, expiresAt)
|
|
||||||
|
|
||||||
if payload.SourceHost != req.SourceHost {
|
|
||||||
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.SourcePath != req.SourcePath {
|
|
||||||
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Width != req.Size.Width {
|
|
||||||
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.ExpiresAt != expiresAt.Unix() {
|
|
||||||
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Request with default values - payload should omit them for
|
|
||||||
// smaller encoding
|
|
||||||
req := &imgcache.ImageRequest{
|
|
||||||
SourceHost: testSourceHost,
|
|
||||||
SourcePath: testSourcePath,
|
|
||||||
Format: encurl.DefaultFormat,
|
|
||||||
Quality: encurl.DefaultQuality,
|
|
||||||
FitMode: encurl.DefaultFitMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
|
|
||||||
|
|
||||||
// These should be zero/empty because they match defaults
|
|
||||||
if payload.Format != "" {
|
|
||||||
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Quality != 0 {
|
|
||||||
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.FitMode != "" {
|
|
||||||
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
||||||
|
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: testSourceHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testSourcePath,
|
Host: testSourceHost,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
Path: testSourcePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(payload)
|
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// Check that token only contains URL-safe characters
|
// Check that token only contains URL-safe characters
|
||||||
for _, c := range token {
|
for _, c := range token {
|
||||||
|
|||||||
+38
-43
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/clientip"
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
req, expiresAt, ttl, err := buildGenerateRequest(parsed, r.Form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@@ -153,21 +153,20 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The image URL must fetch the source URL as it was written
|
// The image URL must fetch the source URL as it was written. The
|
||||||
sourceRequest := payload.ToImageRequest()
|
// fetcher fetches over http only when allow_http is set.
|
||||||
sourceRequest.AllowHTTP = s.config.AllowHTTP
|
fetchURL := req.Source.URL(s.config.AllowHTTP)
|
||||||
fetchURL := sourceRequest.SourceURL()
|
|
||||||
|
|
||||||
if !fetchedAsWritten(parsed, fetchURL) {
|
if !fetchedAsWritten(parsed, fetchURL) {
|
||||||
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
||||||
"written: pixa would fetch "+fetchURL,
|
"written: pixa would fetch "+fetchURL.String(),
|
||||||
r.Form, http.StatusBadRequest)
|
r.Form, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(req, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
@@ -227,27 +226,26 @@ func parseSourceURL(sourceURL string) (*url.URL, error) {
|
|||||||
// in RawPath when it is not the decoded path percent-encoded again.
|
// in RawPath when it is not the decoded path percent-encoded again.
|
||||||
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
|
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
|
||||||
// such as a space, so RawPath is compared itself.
|
// such as a space, so RawPath is compared itself.
|
||||||
func fetchedAsWritten(source *url.URL, fetchURL string) bool {
|
func fetchedAsWritten(source, fetchURL *url.URL) bool {
|
||||||
writtenPath := source.RawPath
|
writtenPath := source.RawPath
|
||||||
if writtenPath == "" {
|
if writtenPath == "" {
|
||||||
writtenPath = source.EscapedPath()
|
writtenPath = source.EscapedPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched, err := url.Parse(fetchURL)
|
return source.User == nil && !source.ForceQuery &&
|
||||||
|
fetchURL.EscapedPath() == writtenPath &&
|
||||||
return err == nil && source.User == nil && !source.ForceQuery &&
|
fetchURL.RawQuery == source.RawQuery
|
||||||
fetched.EscapedPath() == writtenPath &&
|
|
||||||
fetched.RawQuery == source.RawQuery
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
// buildGenerateRequest parses the numeric form fields and assembles the
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
// image request the encrypted URL's token is made from, and its expiry.
|
||||||
// non-numeric or out-of-range width, height, quality or ttl, or an
|
// ttl=0 means never expires (the expiry is the zero time). A non-numeric or
|
||||||
// unrecognized fit mode, is a client error naming the offending field. The
|
// out-of-range width, height, quality or ttl, or an unrecognized fit mode, is
|
||||||
// format field is passed through unchecked.
|
// a client error naming the offending field. The format field is passed
|
||||||
func buildGeneratePayload(
|
// through unchecked.
|
||||||
|
func buildGenerateRequest(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*encurl.Payload, time.Time, int, error) {
|
) (*imagerequest.Request, time.Time, int, error) {
|
||||||
width, err := parseFormDimension(form, "width")
|
width, err := parseFormDimension(form, "width")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, 0, err
|
return nil, time.Time{}, 0, err
|
||||||
@@ -269,42 +267,39 @@ func buildGeneratePayload(
|
|||||||
return nil, time.Time{}, 0, err
|
return nil, time.Time{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fitMode := imgcache.FitMode(form.Get("fit"))
|
fitMode := imagerequest.Fit(form.Get("fit"))
|
||||||
|
|
||||||
err = imgcache.ValidateFitMode(fitMode)
|
err = imagerequest.ValidateFitMode(fitMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, 0,
|
return nil, time.Time{}, 0,
|
||||||
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, form.Get("fit"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var expiresAt time.Time
|
||||||
expiresAt time.Time
|
|
||||||
expiresAtUnix int64
|
|
||||||
)
|
|
||||||
|
|
||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
expiresAtUnix = expiresAt.Unix()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
req := &imagerequest.Request{
|
||||||
SourceHost: parsed.Host,
|
Source: imagerequest.Source{
|
||||||
SourcePath: parsed.Path,
|
Host: parsed.Host,
|
||||||
SourceQuery: parsed.RawQuery,
|
Path: parsed.Path,
|
||||||
Width: width,
|
Query: parsed.RawQuery,
|
||||||
Height: height,
|
},
|
||||||
Format: imgcache.ImageFormat(form.Get("format")),
|
Transform: imagerequest.Transform{
|
||||||
Quality: quality,
|
Size: imagerequest.Size{Width: width, Height: height},
|
||||||
FitMode: fitMode,
|
Format: imagerequest.Format(form.Get("format")),
|
||||||
ExpiresAt: expiresAtUnix,
|
Quality: quality,
|
||||||
|
FitMode: fitMode,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, expiresAt, ttl, nil
|
return req, expiresAt, ttl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFormDimension reads an optional width or height form field. An empty
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
// value means "original size" (0). A non-numeric value, or one
|
// value means "original size" (0). A non-numeric value, or one
|
||||||
// imgcache.ValidateDimension rejects, is an error naming the field.
|
// imagerequest.ValidateDimension rejects, is an error naming the field.
|
||||||
func parseFormDimension(form url.Values, field string) (int, error) {
|
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||||
raw := form.Get(field)
|
raw := form.Get(field)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -316,7 +311,7 @@ func parseFormDimension(form url.Values, field string) (int, error) {
|
|||||||
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = imgcache.ValidateDimension(field, value)
|
err = imagerequest.ValidateDimension(field, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,27 +45,17 @@ func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
|||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() 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{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: sessMgr,
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
encGen: encGen,
|
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
||||||
csrfProtect: protect,
|
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generator form field names, and a value that is not a number.
|
// Generator form field names, and a value that is not a number.
|
||||||
@@ -125,10 +125,10 @@ func TestGeneratePostRejectsBadField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
// TestBuildGenerateRequestDefaultAndLimits verifies that an empty quality
|
||||||
// takes the default, and that the largest accepted width, height, quality and
|
// takes the default, and that the largest accepted width, height, quality and
|
||||||
// ttl are accepted with an expiry still in the future.
|
// ttl are accepted with an expiry still in the future.
|
||||||
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
func TestBuildGenerateRequestDefaultAndLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
parsed, err := url.Parse(testSourceURL)
|
parsed, err := url.Parse(testSourceURL)
|
||||||
@@ -136,19 +136,19 @@ func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
|||||||
t.Fatalf("url.Parse() error = %v", err)
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
req, _, _, err := buildGenerateRequest(parsed, url.Values{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("empty form: error = %v", err)
|
t.Fatalf("empty form: error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Quality != encurl.DefaultQuality {
|
if req.Transform.Quality != encurl.DefaultQuality {
|
||||||
t.Errorf("empty quality gave %d, want %d",
|
t.Errorf("empty quality gave %d, want %d",
|
||||||
payload.Quality, encurl.DefaultQuality)
|
req.Transform.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
_, expiresAt, _, err := buildGenerateRequest(parsed, url.Values{
|
||||||
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
widthField: {strconv.Itoa(imagerequest.MaxDimension)},
|
||||||
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
heightField: {strconv.Itoa(imagerequest.MaxDimension)},
|
||||||
qualityField: {strconv.Itoa(maxQuality)},
|
qualityField: {strconv.Itoa(maxQuality)},
|
||||||
ttlField: {strconv.Itoa(maxTTL)},
|
ttlField: {strconv.Itoa(maxTTL)},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,15 +23,15 @@ func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||||
config: &config.Config{SigningKey: testSigningKey},
|
config: &config.Config{SigningKey: testSigningKey},
|
||||||
sessMgr: sessMgr,
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
submittedKeys := []string{wrongKey, testSigningKey}
|
submittedKeys := []string{wrongKey, testSigningKey}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,8 +233,13 @@ func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
|
|||||||
|
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
sessMgr, err := session.NewManagerWithDatabase(
|
sessMgr, err := session.NewManagerWithDatabase(
|
||||||
t.Context(), testSigningKey, db)
|
t.Context(), keys.SessionHashKey, keys.SessionBlockKey, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -314,7 +320,7 @@ func TestGeneratePost_URLServesImage(t *testing.T) {
|
|||||||
sourceURLField: {"https://" + signedHost + photoPath},
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
widthField: {"50"},
|
widthField: {"50"},
|
||||||
heightField: {"50"},
|
heightField: {"50"},
|
||||||
formatField: {string(imgcache.FormatJPEG)},
|
formatField: {string(imagerequest.FormatJPEG)},
|
||||||
})
|
})
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -347,7 +353,7 @@ func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
|
|||||||
sourceURLField: {"https://" + signedHost + photoPath},
|
sourceURLField: {"https://" + signedHost + photoPath},
|
||||||
widthField: {"50"},
|
widthField: {"50"},
|
||||||
heightField: {"50"},
|
heightField: {"50"},
|
||||||
formatField: {string(imgcache.FormatJPEG)},
|
formatField: {string(imagerequest.FormatJPEG)},
|
||||||
ttlField: {"1"},
|
ttlField: {"1"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and
|
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError, which
|
||||||
// handleImageError, which answer the errors of the two image routes, answer
|
// answers the errors of both image routes, answers 400 naming the AVIF pixel
|
||||||
// 400 naming the AVIF pixel limit when the image processor refuses an AVIF
|
// limit when the image processor refuses an AVIF output above it, wrapped as
|
||||||
// output above it, wrapped as the service wraps it. The request's source only
|
// the service wraps it. The request's source only goes into a log line, so it
|
||||||
// goes into a log line, so it is left empty.
|
// is left empty.
|
||||||
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -25,25 +25,15 @@ func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
|||||||
imageprocessor.ErrAVIFOutputTooLarge)
|
imageprocessor.ErrAVIFOutputTooLarge)
|
||||||
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
||||||
|
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
|
h.respondImageError(rec, &imagerequest.Request{}, err)
|
||||||
|
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
|
||||||
|
|
||||||
encrypted := httptest.NewRecorder()
|
if rec.Code != http.StatusBadRequest {
|
||||||
h.handleImageError(encrypted, err)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
for name, rec := range map[string]*httptest.ResponseRecorder{
|
if !strings.Contains(rec.Body.String(), wantBody) {
|
||||||
"respondImageError": plain, "handleImageError": encrypted,
|
t.Errorf("body = %q, want it to say %q", rec.Body.String(), wantBody)
|
||||||
} {
|
|
||||||
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("%s: status = %d, want %d",
|
|
||||||
name, rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), wantBody) {
|
|
||||||
t.Errorf("%s: body = %q, want it to say %q",
|
|
||||||
name, rec.Body.String(), wantBody)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,27 +33,17 @@ func newBodyLimitTestRouter(
|
|||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() 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{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: sessMgr,
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
encGen: encGen,
|
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
||||||
csrfProtect: protect,
|
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingHost is the allowlisted host whose photoPath the server of
|
||||||
|
// newCountingDatabaseServer serves.
|
||||||
|
const countingHost = "counting.example.com"
|
||||||
|
|
||||||
|
// countingConnector opens connections to the SQLite database file name with
|
||||||
|
// sqliteDriver, each one counting in statements the statements run on it.
|
||||||
|
type countingConnector struct {
|
||||||
|
sqliteDriver driver.Driver
|
||||||
|
name string
|
||||||
|
statements *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConnector) Connect(context.Context) (driver.Conn, error) {
|
||||||
|
conn, err := c.sqliteDriver.Open(c.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return countingConn{Conn: conn, statements: c.statements}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConnector) Driver() driver.Driver {
|
||||||
|
return c.sqliteDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingConn counts the statements run on its connection. It has only the
|
||||||
|
// methods of driver.Conn, so database/sql prepares every query and every
|
||||||
|
// other statement with Prepare, which counts it.
|
||||||
|
type countingConn struct {
|
||||||
|
driver.Conn
|
||||||
|
|
||||||
|
statements *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConn) Prepare(query string) (driver.Stmt, error) {
|
||||||
|
c.statements.Add(1)
|
||||||
|
|
||||||
|
return c.Conn.Prepare(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountingDatabase returns a database with pixa's schema, in a file of
|
||||||
|
// its own and with one connection, and the count of the statements run on
|
||||||
|
// it once the schema is in place.
|
||||||
|
func newCountingDatabase(tb testing.TB) (*sql.DB, *atomic.Int64) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(tb.TempDir(), "state.sqlite3")
|
||||||
|
|
||||||
|
schemaDB, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("opening the database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = schemaDB.Close() }()
|
||||||
|
|
||||||
|
err = database.ApplyMigrations(tb.Context(), schemaDB, nil)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("applying the migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := new(atomic.Int64)
|
||||||
|
|
||||||
|
db := sql.OpenDB(countingConnector{
|
||||||
|
sqliteDriver: schemaDB.Driver(),
|
||||||
|
name: path,
|
||||||
|
statements: statements,
|
||||||
|
})
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
tb.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db, statements
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountingDatabaseServer returns a router for both image routes whose
|
||||||
|
// image cache uses newCountingDatabase, the count of the statements run on
|
||||||
|
// that database, and a URL on each route for a different image of
|
||||||
|
// photoPath on countingHost. Nothing writes the cache's access times in the
|
||||||
|
// background, so every statement counted comes from a request.
|
||||||
|
func newCountingDatabaseServer(
|
||||||
|
tb testing.TB,
|
||||||
|
) (http.Handler, *atomic.Int64, []string) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
db, statements := newCountingDatabase(tb)
|
||||||
|
|
||||||
|
var photo bytes.Buffer
|
||||||
|
|
||||||
|
err := jpeg.Encode(&photo, image.NewRGBA(image.Rect(0, 0, 100, 100)), nil)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encoding the photo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(db, imgcache.Config{
|
||||||
|
StateDir: tb.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
|
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
|
||||||
|
}),
|
||||||
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imageproxy.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 40, Height: 40},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encGen.Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
imgSvc: svc,
|
||||||
|
encGen: encGen,
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{countingHost}),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return r, statements, []string{
|
||||||
|
"/v1/image/" + countingHost + photoPath + "/50x50.jpeg",
|
||||||
|
"/v1/e/" + token + "/img.jpg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveImage sends srv a GET for target and returns the response, failing
|
||||||
|
// the test unless it is a 200 with an image.
|
||||||
|
func serveImage(
|
||||||
|
tb testing.TB, srv http.Handler, target string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec,
|
||||||
|
httptest.NewRequestWithContext(tb.Context(), http.MethodGet, target, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.Len() == 0 {
|
||||||
|
tb.Errorf("GET %s: %d with %d bytes, want 200 with an image",
|
||||||
|
target, rec.Code, rec.Body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_CacheHitsMakeNoDatabaseCall serves an image on each image
|
||||||
|
// route once, which makes and caches it, then serves it again from the cache.
|
||||||
|
// Serving those cache hits must run no statement on the database.
|
||||||
|
func TestImageRoutes_CacheHitsMakeNoDatabaseCall(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv, statements, targets := newCountingDatabaseServer(t)
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
before := statements.Load()
|
||||||
|
|
||||||
|
serveImage(t, srv, target)
|
||||||
|
|
||||||
|
afterMiss := statements.Load()
|
||||||
|
t.Logf("GET %s, a miss: %d statements", target, afterMiss-before)
|
||||||
|
|
||||||
|
if afterMiss == before {
|
||||||
|
t.Fatalf("GET %s, a miss: no statement counted, want some", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
rec := serveImage(t, srv, target)
|
||||||
|
|
||||||
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
if cacheStatus != string(imageproxy.CacheHit) {
|
||||||
|
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imageproxy.CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hitStatements := statements.Load() - afterMiss; hitStatements != 0 {
|
||||||
|
t.Errorf("GET %s: 3 cache hits ran %d statements, want 0",
|
||||||
|
target, hitStatements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkHandleImage_CacheHits serves one cached image through the
|
||||||
|
// /v1/image/ route from b.RunParallel's goroutines, and reports the cache
|
||||||
|
// hits served per second. It fails if serving them ran any statement on the
|
||||||
|
// database. script/bench runs it.
|
||||||
|
func BenchmarkHandleImage_CacheHits(b *testing.B) {
|
||||||
|
srv, statements, targets := newCountingDatabaseServer(b)
|
||||||
|
target := targets[0]
|
||||||
|
|
||||||
|
serveImage(b, srv, target)
|
||||||
|
|
||||||
|
before := statements.Load()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
rec := serveImage(b, srv, target)
|
||||||
|
|
||||||
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
if cacheStatus != string(imageproxy.CacheHit) {
|
||||||
|
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imageproxy.CacheHit)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "hits/s")
|
||||||
|
|
||||||
|
if hitStatements := statements.Load() - before; hitStatements != 0 {
|
||||||
|
b.Fatalf("%d cache hits ran %d statements, want 0", b.N, hitStatements)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
|
|
||||||
// built from cache_max_bytes: omitted, the cache works out the default
|
|
||||||
// limit; 0 turns the disk cache off; a positive value is the limit,
|
|
||||||
// unchanged.
|
|
||||||
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const oneGiB = 1 << 30
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cacheMaxBytes int64
|
|
||||||
cacheMaxBytesExplicit bool
|
|
||||||
wantMaxBytes int64
|
|
||||||
wantUseDefaultMaxBytes bool
|
|
||||||
wantDisableDiskCache bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "cache_max_bytes omitted",
|
|
||||||
cacheMaxBytes: 0,
|
|
||||||
cacheMaxBytesExplicit: false,
|
|
||||||
wantMaxBytes: 0,
|
|
||||||
wantUseDefaultMaxBytes: true,
|
|
||||||
wantDisableDiskCache: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cache_max_bytes: 0",
|
|
||||||
cacheMaxBytes: 0,
|
|
||||||
cacheMaxBytesExplicit: true,
|
|
||||||
wantMaxBytes: 0,
|
|
||||||
wantUseDefaultMaxBytes: false,
|
|
||||||
wantDisableDiskCache: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cache_max_bytes: 1 GiB",
|
|
||||||
cacheMaxBytes: oneGiB,
|
|
||||||
cacheMaxBytesExplicit: true,
|
|
||||||
wantMaxBytes: oneGiB,
|
|
||||||
wantUseDefaultMaxBytes: false,
|
|
||||||
wantDisableDiskCache: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
CacheMaxBytes: tc.cacheMaxBytes,
|
|
||||||
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := newCacheConfig(cfg, nil)
|
|
||||||
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
|
|
||||||
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
|
|
||||||
|
|
||||||
if got.MaxBytes != tc.wantMaxBytes {
|
|
||||||
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
|
|
||||||
t.Errorf("UseDefaultMaxBytes = %v, want %v",
|
|
||||||
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.DisableDiskCache != tc.wantDisableDiskCache {
|
|
||||||
t.Errorf("DisableDiskCache = %v, want %v",
|
|
||||||
got.DisableDiskCache, tc.wantDisableDiskCache)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
|
|
||||||
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
|
|
||||||
// and checks by whether the cache directories were created that the
|
|
||||||
// disk cache is on in the first case and off in the second.
|
|
||||||
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cacheMaxBytesExplicit bool
|
|
||||||
wantDiskCache bool
|
|
||||||
}{
|
|
||||||
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
|
|
||||||
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
cfg := &config.Config{
|
|
||||||
SigningKey: testSigningKey,
|
|
||||||
StateDir: stateDir,
|
|
||||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
|
||||||
CacheMaxBytes: 0,
|
|
||||||
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
|
||||||
}
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("database.New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lc.RequireStart()
|
|
||||||
t.Cleanup(lc.RequireStop)
|
|
||||||
|
|
||||||
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
|
|
||||||
gotDiskCache := err == nil
|
|
||||||
|
|
||||||
if gotDiskCache != tc.wantDiskCache {
|
|
||||||
t.Errorf("cache directories created = %v, want %v",
|
|
||||||
gotDiskCache, tc.wantDiskCache)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// heldFetcher sends on started when a fetch begins, and holds the fetch until
|
||||||
|
// release is closed. It then fails the fetch as an upstream error.
|
||||||
|
type heldFetcher struct {
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch sends on f.started, waits for f.release to be closed, and returns
|
||||||
|
// httpfetcher.ErrUpstreamError.
|
||||||
|
func (f heldFetcher) Fetch(
|
||||||
|
context.Context, imagerequest.Source, string, string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
f.started <- struct{}{}
|
||||||
|
|
||||||
|
<-f.release
|
||||||
|
|
||||||
|
return nil, httpfetcher.ErrUpstreamError
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLogLines decodes the JSON log lines in logs.
|
||||||
|
func readLogLines(t *testing.T, logs *bytes.Buffer) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Logf("logged:\n%s", logs.String())
|
||||||
|
|
||||||
|
var lines []map[string]any
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_ClientGoneWhileWaitingAnswers499 checks that both image
|
||||||
|
// routes answer 499 to a request whose client goes away while it waits for an
|
||||||
|
// image another request is making. A first request's fetch is held, so the
|
||||||
|
// image is being made, and a second request for the same image waits for it
|
||||||
|
// until its client goes away. The second request must be logged once at info
|
||||||
|
// level with its request ID and the source host and path. Nothing may be
|
||||||
|
// logged at error level.
|
||||||
|
func TestImageRoutes_ClientGoneWhileWaitingAnswers499(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const requestID = "test-request-id"
|
||||||
|
|
||||||
|
for route, imageURL := range photoURLsByRoute() {
|
||||||
|
t.Run(route, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
fetcher := heldFetcher{started: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
h, routes := newRoutesFetchingThrough(t, fetcher,
|
||||||
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||||
|
target := imageURL(t, h)
|
||||||
|
|
||||||
|
firstDone := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(firstDone)
|
||||||
|
|
||||||
|
sendGet(t, routes, target)
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-fetcher.started
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.WithValue(t.Context(),
|
||||||
|
middleware.RequestIDKey, requestID))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
secondDone := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(secondDone)
|
||||||
|
|
||||||
|
routes.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
ctx, http.MethodGet, target, nil))
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the second request time to start waiting for the first
|
||||||
|
// request's image. The fetch is still held, so the second request
|
||||||
|
// can end only because its client went away
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
<-secondDone
|
||||||
|
|
||||||
|
close(fetcher.release)
|
||||||
|
<-firstDone
|
||||||
|
|
||||||
|
if rec.Code != 499 {
|
||||||
|
t.Errorf("status = %d, want 499", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientGoneLines int
|
||||||
|
|
||||||
|
for _, line := range readLogLines(t, &logs) {
|
||||||
|
if line["level"] == slog.LevelError.String() {
|
||||||
|
t.Errorf("logged at error level: %v", line["msg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if line["level"] == slog.LevelInfo.String() &&
|
||||||
|
line["msg"] == "client went away before the image was ready" &&
|
||||||
|
line["request_id"] == requestID &&
|
||||||
|
line["host"] == signedHost &&
|
||||||
|
line["path"] == photoPath {
|
||||||
|
clientGoneLines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientGoneLines != 1 {
|
||||||
|
t.Errorf("info line naming the request logged %d times, want 1",
|
||||||
|
clientGoneLines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_CanceledWithLiveRequestAnswers500 checks that both image
|
||||||
|
// routes answer 500, logged as "failed to get image" at error level, for an
|
||||||
|
// image service error that wraps context.Canceled while the request's own
|
||||||
|
// context is live. Its client has not gone away, so the cancellation is
|
||||||
|
// pixa's own failure.
|
||||||
|
func TestImageRoutes_CanceledWithLiveRequestAnswers500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for route, imageURL := range photoURLsByRoute() {
|
||||||
|
t.Run(route, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
h, routes := newRoutesFetchingThrough(t,
|
||||||
|
failingFetcher{err: context.Canceled},
|
||||||
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||||
|
|
||||||
|
rec := sendGet(t, routes, imageURL(t, h))
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want %d",
|
||||||
|
rec.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.ContainsFunc(readLogLines(t, &logs),
|
||||||
|
func(line map[string]any) bool {
|
||||||
|
return line["level"] == slog.LevelError.String() &&
|
||||||
|
line["msg"] == "failed to get image"
|
||||||
|
}) {
|
||||||
|
t.Error(`no "failed to get image" line logged at error level`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,15 +9,10 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/seal"
|
"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
|
// newCSRFProtect builds the CSRF-protection middleware for the
|
||||||
// state-mutating HTML form routes. The token lives in its own cookie,
|
// state-mutating HTML form routes. The token lives in its own cookie,
|
||||||
// independent of the session cookie, so it also protects the login POST
|
// independent of the session cookie, so it also protects the login POST
|
||||||
// where no session exists yet (login CSRF).
|
// where no session exists yet (login CSRF). key signs that cookie.
|
||||||
//
|
//
|
||||||
// When plaintext is true (local HTTP development), requests are marked
|
// When plaintext is true (local HTTP development), requests are marked
|
||||||
// plaintext so the library neither demands an https Referer nor sets a
|
// plaintext so the library neither demands an https Referer nor sets a
|
||||||
@@ -25,13 +20,8 @@ const csrfKeySalt = "pixa-csrf-v1"
|
|||||||
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
||||||
// the library enforces its https Referer origin check.
|
// the library enforces its https Referer origin check.
|
||||||
func newCSRFProtect(
|
func newCSRFProtect(
|
||||||
signingKey string, plaintext bool,
|
key [seal.KeySize]byte, plaintext bool,
|
||||||
) (func(http.Handler) http.Handler, error) {
|
) func(http.Handler) http.Handler {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
protect := csrf.Protect(
|
protect := csrf.Protect(
|
||||||
key[:],
|
key[:],
|
||||||
csrf.Path("/"),
|
csrf.Path("/"),
|
||||||
@@ -40,7 +30,7 @@ func newCSRFProtect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !plaintext {
|
if !plaintext {
|
||||||
return protect, nil
|
return protect
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
@@ -49,7 +39,7 @@ func newCSRFProtect(
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
||||||
})
|
})
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF returns the CSRF-protection middleware for the login and generator
|
// CSRF returns the CSRF-protection middleware for the login and generator
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided builds the handlers as
|
|
||||||
// pixad does, in an fx app that provides no fetcher, and requests an image
|
|
||||||
// from 192.0.2.10, which is on the allowlist and in blocked_networks. The URL
|
|
||||||
// check accepts that address; only the dialer that refuses internal
|
|
||||||
// addresses checks blocked_networks, so the answer is 403 only if the
|
|
||||||
// fetcher the handlers build from the config connects with that dialer. Any
|
|
||||||
// other dialer would try to connect until the upstream fetch timeout, which
|
|
||||||
// is short so that the test then fails quickly.
|
|
||||||
func TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const host = "192.0.2.10"
|
|
||||||
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
cfg := &config.Config{
|
|
||||||
SigningKey: testSigningKey,
|
|
||||||
StateDir: stateDir,
|
|
||||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
|
||||||
AllowlistHosts: []string{host},
|
|
||||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
|
||||||
UpstreamFetchTimeout: 2 * time.Second,
|
|
||||||
// With no connection slots, the fetch would fail before dialing.
|
|
||||||
UpstreamConnections: config.DefaultUpstreamConnections,
|
|
||||||
}
|
|
||||||
|
|
||||||
var h *Handlers
|
|
||||||
|
|
||||||
app := fxtest.New(t,
|
|
||||||
fx.Supply(cfg),
|
|
||||||
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New),
|
|
||||||
fx.Populate(&h),
|
|
||||||
)
|
|
||||||
app.RequireStart()
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/v1/image/*", h.HandleImage())
|
|
||||||
|
|
||||||
rec := sendGet(t, r, photoURL(host))
|
|
||||||
checkErrorBody(t, rec, http.StatusForbidden, "forbidden")
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mismatchedSourcePath is the path of the source that
|
// mismatchedSourcePath is the path of the source that
|
||||||
@@ -24,38 +31,51 @@ import (
|
|||||||
const mismatchedSourcePath = "/images/source.png"
|
const mismatchedSourcePath = "/images/source.png"
|
||||||
|
|
||||||
// newRoutesFetchingThrough returns both image routes, and the Handlers behind
|
// newRoutesFetchingThrough returns both image routes, and the Handlers behind
|
||||||
// them, whose image service fetches through fetcher and has allowlistedHost on
|
// them, whose image service fetches through fetcher, and which have
|
||||||
// its allowlist. The handlers and the image service log to log.
|
// allowlistedHost on their allowlist. The handlers and the image service log
|
||||||
|
// to log.
|
||||||
func newRoutesFetchingThrough(
|
func newRoutesFetchingThrough(
|
||||||
t *testing.T, fetcher httpfetcher.Fetcher, log *slog.Logger,
|
t *testing.T, fetcher httpfetcher.Fetcher, log *slog.Logger,
|
||||||
) (*Handlers, http.Handler) {
|
) (*Handlers, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{allowlistedHost},
|
MaxInputBytes: 50 << 20,
|
||||||
Logger: log,
|
MaxConcurrentProcessing: 2,
|
||||||
})
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
Logger: log,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
t.Fatalf("imageproxy.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{log: log, imgSvc: svc, encGen: encGen}
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: log,
|
||||||
|
imgSvc: svc,
|
||||||
|
encGen: encGen,
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{allowlistedHost}),
|
||||||
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", h.HandleImage())
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
@@ -75,13 +95,16 @@ func mismatchedSourcePlainURL(*testing.T, *Handlers) string {
|
|||||||
func mismatchedSourceEncURL(t *testing.T, h *Handlers) string {
|
func mismatchedSourceEncURL(t *testing.T, h *Handlers) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: allowlistedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: mismatchedSourcePath,
|
Host: allowlistedHost,
|
||||||
Width: 50,
|
Path: mismatchedSourcePath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
})
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Errors for an Accept header that an auto URL cannot be served for.
|
// Errors for an Accept header that an auto URL cannot be served for.
|
||||||
@@ -18,16 +18,17 @@ var (
|
|||||||
"image/jxl, image/avif, image/webp or image/jpeg")
|
"image/jxl, image/avif, image/webp or image/jpeg")
|
||||||
)
|
)
|
||||||
|
|
||||||
// chooseAutoFormat replaces the format auto in req with the format
|
// chooseAutoFormat returns the format to serve for the requested format: for
|
||||||
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
|
// the format auto, the format formatForAccept chooses from r's Accept header,
|
||||||
|
// and any other format as it is. For auto it adds Vary: Accept to the
|
||||||
// response, which then depends on that header. It answers 400 for an Accept
|
// 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,
|
// 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.
|
// and reports whether the image can be served.
|
||||||
func (s *Handlers) chooseAutoFormat(
|
func (s *Handlers) chooseAutoFormat(
|
||||||
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest,
|
w http.ResponseWriter, r *http.Request, requested imagerequest.Format,
|
||||||
) bool {
|
) (imagerequest.Format, bool) {
|
||||||
if req.Format != imgcache.FormatAuto {
|
if requested != imagerequest.FormatAuto {
|
||||||
return true
|
return requested, true
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Vary", "Accept")
|
w.Header().Add("Vary", "Accept")
|
||||||
@@ -36,18 +37,16 @@ func (s *Handlers) chooseAutoFormat(
|
|||||||
if errors.Is(err, errNotAcceptable) {
|
if errors.Is(err, errNotAcceptable) {
|
||||||
s.respondError(w, err.Error(), http.StatusNotAcceptable)
|
s.respondError(w, err.Error(), http.StatusNotAcceptable)
|
||||||
|
|
||||||
return false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, err.Error(), http.StatusBadRequest)
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Format = format
|
return format, true
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatForAccept returns the format an auto URL is served in for the Accept
|
// formatForAccept returns the format an auto URL is served in for the Accept
|
||||||
@@ -56,30 +55,30 @@ func (s *Handlers) chooseAutoFormat(
|
|||||||
// specific entry of image/jpeg, image/* and */* allows it, or when it names
|
// specific entry of image/jpeg, image/* and */* allows it, or when it names
|
||||||
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
|
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
|
||||||
// clients that cannot show them send image/* and */* too.
|
// clients that cannot show them send image/* and */* too.
|
||||||
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
func formatForAccept(accept string) (imagerequest.Format, error) {
|
||||||
qualities, err := parseAccept(accept)
|
qualities, err := parseAccept(accept)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(qualities) == 0 {
|
if len(qualities) == 0 {
|
||||||
return imgcache.FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/jxl"] > 0 {
|
if qualities[imagerequest.FormatJXL.MIMEType()] > 0 {
|
||||||
return imgcache.FormatJXL, nil
|
return imagerequest.FormatJXL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/avif"] > 0 {
|
if qualities[imagerequest.FormatAVIF.MIMEType()] > 0 {
|
||||||
return imgcache.FormatAVIF, nil
|
return imagerequest.FormatAVIF, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/webp"] > 0 {
|
if qualities[imagerequest.FormatWebP.MIMEType()] > 0 {
|
||||||
return imgcache.FormatWebP, nil
|
return imagerequest.FormatWebP, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For JPEG, the most specific entry the header has decides
|
// For JPEG, the most specific entry the header has decides
|
||||||
quality, named := qualities["image/jpeg"]
|
quality, named := qualities[imagerequest.FormatJPEG.MIMEType()]
|
||||||
if !named {
|
if !named {
|
||||||
quality, named = qualities["image/*"]
|
quality, named = qualities["image/*"]
|
||||||
}
|
}
|
||||||
@@ -89,7 +88,7 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if quality > 0 {
|
if quality > 0 {
|
||||||
return imgcache.FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errNotAcceptable
|
return "", errNotAcceptable
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The content types the tests below expect.
|
// The content types the tests below expect.
|
||||||
@@ -33,24 +32,24 @@ func TestFormatForAccept(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
accept string
|
accept string
|
||||||
want imgcache.ImageFormat
|
want imagerequest.Format
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"AVIF-capable browser",
|
{"AVIF-capable browser",
|
||||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
imgcache.FormatAVIF, nil},
|
imagerequest.FormatAVIF, nil},
|
||||||
{"WebP-capable browser",
|
{"WebP-capable browser",
|
||||||
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
||||||
imgcache.FormatWebP, nil},
|
imagerequest.FormatWebP, nil},
|
||||||
{"WebP only", webpType, imgcache.FormatWebP, nil},
|
{"WebP only", webpType, imagerequest.FormatWebP, nil},
|
||||||
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
|
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imagerequest.FormatJPEG, nil},
|
||||||
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
|
{"wildcard only", "*/*", imagerequest.FormatJPEG, nil},
|
||||||
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
|
{"image wildcard only", "image/*", imagerequest.FormatJPEG, nil},
|
||||||
{"absent", "", imgcache.FormatJPEG, nil},
|
{"absent", "", imagerequest.FormatJPEG, nil},
|
||||||
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
|
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imagerequest.FormatWebP, nil},
|
||||||
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
||||||
imgcache.FormatJPEG, nil},
|
imagerequest.FormatJPEG, nil},
|
||||||
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
|
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imagerequest.FormatAVIF, nil},
|
||||||
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
|
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
|
||||||
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
|
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
|
||||||
{"PNG only", "image/png", "", errNotAcceptable},
|
{"PNG only", "image/png", "", errNotAcceptable},
|
||||||
@@ -76,28 +75,33 @@ func TestFormatForAccept(t *testing.T) {
|
|||||||
|
|
||||||
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
|
// 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
|
// 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.
|
// the format auto. The first is signed with testSigningKey, the key the
|
||||||
|
// handlers that newSignedHostServer builds check signatures with. The second
|
||||||
|
// is made with h's generator.
|
||||||
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatAuto,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatAuto,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatAuto,
|
Transform: imagerequest.Transform{
|
||||||
ExpiresAt: time.Now().Add(time.Minute).Unix(),
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
})
|
Format: imagerequest.FormatAuto,
|
||||||
|
},
|
||||||
|
}, time.Now().Add(time.Minute))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -182,17 +186,18 @@ func TestFormatAuto_ChosenFromAccept(t *testing.T) {
|
|||||||
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedForAVIF := generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatAVIF,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatAVIF,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
|
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
|
||||||
if target == signedForAVIF {
|
if target == signedForAVIF {
|
||||||
|
|||||||
+21
-132
@@ -4,7 +4,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,13 +11,13 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/allowlist"
|
"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/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/imageproxy"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines dependencies for Handlers.
|
// Params defines dependencies for Handlers.
|
||||||
@@ -27,70 +26,48 @@ type Params struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
|
Keys seal.Keys
|
||||||
|
|
||||||
// Fetcher, when provided, fetches upstream images in place of the
|
ImageService *imageproxy.Proxy
|
||||||
// fetcher the handlers build from the config. Only tests provide one;
|
SessionManager *session.Manager
|
||||||
// pixad does not.
|
EncryptedURLGenerator *encurl.Generator
|
||||||
Fetcher httpfetcher.Fetcher `optional:"true"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
db *database.Database
|
|
||||||
config *config.Config
|
config *config.Config
|
||||||
fetcher httpfetcher.Fetcher
|
imgSvc *imageproxy.Proxy
|
||||||
imgSvc *imgcache.Service
|
|
||||||
imgCache *imgcache.Cache
|
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
csrfProtect func(http.Handler) http.Handler
|
csrfProtect func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
// signer checks the signature of a /v1/image/ URL with signing_key.
|
||||||
|
signer *signature.Signer
|
||||||
|
// allowlist matches the hosts of allowlist_hosts, which a /v1/image/
|
||||||
|
// URL may name without a signature.
|
||||||
|
allowlist *allowlist.HostAllowList
|
||||||
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
|
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
|
||||||
// reports whether a URL's host is on that list.
|
// reports whether a URL's host is on that list.
|
||||||
refererBlocklist *allowlist.HostAllowList
|
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(_ fx.Lifecycle, params Params) *Handlers {
|
||||||
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
return &Handlers{
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Handlers{
|
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
fetcher: params.Fetcher,
|
imgSvc: params.ImageService,
|
||||||
csrfProtect: csrfProtect,
|
sessMgr: params.SessionManager,
|
||||||
|
encGen: params.EncryptedURLGenerator,
|
||||||
|
csrfProtect: newCSRFProtect(params.Keys.CSRFKey, params.Config.Debug),
|
||||||
|
signer: signature.New(params.Config.SigningKey),
|
||||||
|
allowlist: allowlist.New(params.Config.AllowlistHosts),
|
||||||
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
|
||||||
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
|
|
||||||
OnStart: func(_ context.Context) error {
|
|
||||||
return s.initImageService()
|
|
||||||
},
|
|
||||||
// The pending counts are written here, before the database's stop
|
|
||||||
// hook closes it.
|
|
||||||
OnStop: func(ctx context.Context) error {
|
|
||||||
if s.imgCache == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Join(
|
|
||||||
s.imgCache.StopEviction(ctx),
|
|
||||||
s.imgCache.StopPendingCountWrites(ctx),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
@@ -99,94 +76,6 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|||||||
return s.imgSvc.WaitForProcessing(ctx)
|
return s.imgSvc.WaitForProcessing(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCacheConfig builds the image cache's configuration from cfg.
|
|
||||||
// cache_max_bytes: 0 disables the disk cache entirely; any other value
|
|
||||||
// is the eviction limit in bytes; when it is omitted, the cache works
|
|
||||||
// out the default limit itself.
|
|
||||||
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
|
||||||
return imgcache.CacheConfig{
|
|
||||||
StateDir: cfg.StateDir,
|
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
|
||||||
MaxBytes: cfg.CacheMaxBytes,
|
|
||||||
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
|
||||||
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
|
|
||||||
Logger: log,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initImageService initializes the image cache and service.
|
|
||||||
func (s *Handlers) initImageService() error {
|
|
||||||
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.imgCache = cache
|
|
||||||
|
|
||||||
// Background eviction: startup reconciliation, then periodic and
|
|
||||||
// write-pressure passes, and the removal of expired negative cache
|
|
||||||
// rows, which is all it does when the disk cache is disabled.
|
|
||||||
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
|
||||||
|
|
||||||
// Writes the counts requests could not write by their deadline
|
|
||||||
cache.StartPendingCountWrites()
|
|
||||||
|
|
||||||
// Create the fetcher config
|
|
||||||
fetcherCfg := httpfetcher.DefaultConfig()
|
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
|
||||||
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
|
||||||
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
|
||||||
|
|
||||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
|
||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
|
||||||
Cache: cache,
|
|
||||||
FetcherConfig: fetcherCfg,
|
|
||||||
Fetcher: s.fetcher,
|
|
||||||
SigningKey: s.config.SigningKey,
|
|
||||||
Allowlist: s.config.AllowlistHosts,
|
|
||||||
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
|
||||||
Logger: s.log,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.imgSvc = svc
|
|
||||||
s.log.Info("image service initialized")
|
|
||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load
|
|
||||||
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
|
||||||
// The random value that each logout replaces is saved in the database,
|
|
||||||
// so it outlasts a restart.
|
|
||||||
sessMgr, err := session.NewManagerWithDatabase(
|
|
||||||
context.Background(), s.config.SigningKey, s.db.DB())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.sessMgr = sessMgr
|
|
||||||
|
|
||||||
// Initialize encrypted URL generator
|
|
||||||
encGen, err := encurl.NewGenerator(s.config.SigningKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.encGen = encGen
|
|
||||||
|
|
||||||
s.log.Info("session manager and URL generator initialized")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, 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)
|
||||||
|
|||||||
@@ -18,15 +18,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testFixtures contains test data.
|
// testFixtures contains test data.
|
||||||
type testFixtures struct {
|
type testFixtures struct {
|
||||||
handler *Handlers
|
handler *Handlers
|
||||||
service *imgcache.Service
|
|
||||||
goodHost string
|
goodHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,32 +50,37 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(db, imgcache.Config{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
SigningKey: "test-signing-key-must-be-32-chars",
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{goodHost},
|
MaxInputBytes: 50 << 20,
|
||||||
})
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create proxy: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
imgSvc: svc,
|
imgSvc: svc,
|
||||||
log: slog.Default(),
|
log: slog.Default(),
|
||||||
|
signer: signature.New("test-signing-key-must-be-32-chars"),
|
||||||
|
allowlist: allowlist.New([]string{goodHost}),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &testFixtures{
|
return &testFixtures{
|
||||||
handler: h,
|
handler: h,
|
||||||
service: svc,
|
|
||||||
goodHost: goodHost,
|
goodHost: goodHost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,10 +131,9 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(
|
func (f *mockFetcher) Fetch(
|
||||||
_ context.Context, url string,
|
_ context.Context, source imagerequest.Source, _, _ string,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
// Remove https:// prefix
|
path := source.Host + source.Path
|
||||||
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 {
|
||||||
@@ -248,6 +257,90 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_ConditionalRequest_EarlierETagGetsChangedImage serves an
|
||||||
|
// image, changes it upstream, and requests it again with the ETag first
|
||||||
|
// served, once the cache TTL has passed. The changed image must be served,
|
||||||
|
// not 304 Not Modified.
|
||||||
|
func TestHandleImage_ConditionalRequest_EarlierETagGetsChangedImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "goodhost.example.com"
|
||||||
|
|
||||||
|
upstream := fstest.MapFS{
|
||||||
|
host + photoPath: &fstest.MapFile{
|
||||||
|
Data: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cache TTL of one nanosecond has passed by the second request
|
||||||
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
||||||
|
StateDir: t.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
CacheTTL: time.Nanosecond,
|
||||||
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: newMockFetcher(upstream),
|
||||||
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imageproxy.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
imgSvc: svc,
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{host}),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
|
||||||
|
target := "/v1/image/" + host + photoPath + "/50x50.jpeg"
|
||||||
|
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(first,
|
||||||
|
httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil))
|
||||||
|
|
||||||
|
etag := first.Header().Get("ETag")
|
||||||
|
if first.Code != http.StatusOK || etag == "" {
|
||||||
|
t.Fatalf("first request: status = %d, ETag = %q, want %d and an ETag",
|
||||||
|
first.Code, etag, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream[host+photoPath] = &fstest.MapFile{
|
||||||
|
Data: generateTestJPEG(t, 100, 100, color.RGBA{0, 0, 255, 255}),
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(second, req)
|
||||||
|
t.Logf("first ETag %s; with it after the change: status %d, ETag %s, %d bytes",
|
||||||
|
etag, second.Code, second.Header().Get("ETag"), second.Body.Len())
|
||||||
|
|
||||||
|
if second.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d with the changed image",
|
||||||
|
second.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(second.Body.Bytes(), first.Body.Bytes()) {
|
||||||
|
t.Error("the image served is the one made before the change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleImage_ETagHeader(t *testing.T) {
|
func TestHandleImage_ETagHeader(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+164
-110
@@ -1,22 +1,31 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// statusClientClosedRequest is the status nginx calls "client closed
|
||||||
|
// request". The image routes answer it when the client goes away before its
|
||||||
|
// image is ready, unless its request is the one making the image. Nobody reads
|
||||||
|
// it; it is for logs and metrics.
|
||||||
|
const statusClientClosedRequest = 499
|
||||||
|
|
||||||
// HandleImage handles the main image proxy route:
|
// HandleImage handles the main image proxy route:
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
|
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
|
||||||
// /v1/image/<host>/<path>/<width>x<height>
|
// /v1/image/<host>/<path>/<width>x<height>
|
||||||
@@ -26,17 +35,17 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req, ok := s.parseImageRequest(w, r)
|
req, sig, expires, ok := s.parseImageRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate signature if required
|
// Validate signature if required
|
||||||
err := s.imgSvc.ValidateRequest(req)
|
err := s.validateSignature(req, sig, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("signature validation failed",
|
s.log.Warn("signature validation failed",
|
||||||
"host", req.SourceHost,
|
"host", req.Source.Host,
|
||||||
"path", req.SourcePath,
|
"path", req.Source.Path,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -44,27 +53,7 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The signature covers the format auto, not the format chosen
|
s.serveImage(w, r, req, expires)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,17 +70,17 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseImageRequest parses the image URL's path and query parameters into
|
// parseImageRequest parses the image URL's path and query parameters into
|
||||||
// an ImageRequest. On invalid input it writes an error response and
|
// an image request, and returns it with the URL's signature and expiry. On
|
||||||
// returns false.
|
// invalid input it writes an error response and returns false.
|
||||||
func (s *Handlers) parseImageRequest(
|
func (s *Handlers) parseImageRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imgcache.ImageRequest, bool) {
|
) (*imagerequest.Request, string, time.Time, bool) {
|
||||||
// The path is parsed decoded once, as r.URL.Path holds it, and not from
|
// The path is parsed decoded once, as r.URL.Path holds it, and not from
|
||||||
// chi's wildcard: chi routes on the path as the client wrote it whenever
|
// chi's wildcard: chi routes on the path as the client wrote it whenever
|
||||||
// that is not Go's own encoding of it, so its wildcard would still hold
|
// that is not Go's own encoding of it, so its wildcard would still hold
|
||||||
// the %3F before a source query when, say, a "/" in the query is written
|
// the %3F before a source query when, say, a "/" in the query is written
|
||||||
// as %2F
|
// as %2F
|
||||||
parsed, err := imgcache.ParseImageURL(r.URL.Path)
|
req, err := imagerequest.ParseImageURL(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to parse image URL",
|
s.log.Warn("failed to parse image URL",
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
@@ -99,12 +88,9 @@ func (s *Handlers) parseImageRequest(
|
|||||||
)
|
)
|
||||||
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to ImageRequest
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
// Parse signature params from query string. r.URL.Query() would silently
|
// Parse signature params from query string. r.URL.Query() would silently
|
||||||
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
||||||
// at 85; a query string that cannot be decoded is refused instead. A
|
// at 85; a query string that cannot be decoded is refused instead. A
|
||||||
@@ -115,7 +101,7 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
||||||
r.URL.RawQuery, err), http.StatusBadRequest)
|
r.URL.RawQuery, err), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, values := range query {
|
for name, values := range query {
|
||||||
@@ -123,17 +109,15 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
||||||
name), http.StatusBadRequest)
|
name), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Signature = query.Get("sig")
|
expires, err := parseExpires(query)
|
||||||
|
|
||||||
req.Expires, err = parseExpires(query)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, err.Error(), http.StatusBadRequest)
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse optional quality and fit params. Only a q missing from the URL is
|
// Parse optional quality and fit params. Only a q missing from the URL is
|
||||||
@@ -145,51 +129,76 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, `invalid q: not a number, got ""`,
|
s.respondError(w, `invalid q: not a number, got ""`,
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Quality, err = parseFormInt(query, "q",
|
req.Transform.Quality, err = parseFormInt(query, "q",
|
||||||
encurl.DefaultQuality, minQuality, maxQuality)
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
||||||
// fit mode is refused by the fit-mode check below; that check would take an
|
// fit mode is refused by the fit-mode check below; that check would take an
|
||||||
// empty fit as missing, so an empty one is refused here.
|
// empty fit as missing, so an empty one is refused here.
|
||||||
req.FitMode = imgcache.FitMode(query.Get("fit"))
|
req.Transform.FitMode = imagerequest.Fit(query.Get("fit"))
|
||||||
if query.Has("fit") && req.FitMode == "" {
|
if query.Has("fit") && req.Transform.FitMode == "" {
|
||||||
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
if req.FitMode == "" {
|
if req.Transform.FitMode == "" {
|
||||||
req.FitMode = imgcache.FitCover
|
req.Transform.FitMode = imagerequest.FitCover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||||
// route. Dimensions are already bounded by the path parser above; this
|
// route. Dimensions are already bounded by the path parser above; this
|
||||||
// also rejects an unrecognized fit mode with 400 instead of letting it
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||||
// reach the processor as a 500.
|
// reach the processor as a 500.
|
||||||
err = imgcache.ValidateImageRequest(req)
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.respondError(w, "invalid image request: "+err.Error(),
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, "", time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, true
|
return req, query.Get("sig"), expires, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSignature returns nil when the source host of req is on
|
||||||
|
// allowlist_hosts and names no port. Otherwise it verifies sig, the URL's
|
||||||
|
// signature of req, and expires, the URL's expiry.
|
||||||
|
func (s *Handlers) validateSignature(
|
||||||
|
req *imagerequest.Request, sig string, expires time.Time,
|
||||||
|
) error {
|
||||||
|
// An allowlist entry names no port, so a host followed by ":", with or
|
||||||
|
// without a port, needs a signature. Go's URL type reads the port as the
|
||||||
|
// fetcher does: in an IPv6 address written without brackets, such as
|
||||||
|
// 2001:db8::1:8443, it is what follows the last ":". This is checked here
|
||||||
|
// and not in allowlist.IsAllowed, which also matches referer_blocklist,
|
||||||
|
// where a Referer must be blocked whatever its port. Port and IsAllowed
|
||||||
|
// read only the host of a URL.
|
||||||
|
sourceURL := &url.URL{Host: req.Source.Host}
|
||||||
|
hostHasPort := sourceURL.Port() != "" ||
|
||||||
|
strings.HasSuffix(sourceURL.Host, ":")
|
||||||
|
|
||||||
|
if !hostHasPort && s.allowlist.IsAllowed(sourceURL) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature required for non-allowed hosts
|
||||||
|
return s.signer.Verify(req, sig, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
||||||
// missing from the URL gives the zero time, which the signature check takes
|
// missing from the URL gives the zero time, which the signature check refuses
|
||||||
// as no expiration. An exp in the URL that is not a whole number, an empty
|
// as a missing exp. An exp in the URL that is not a whole number, an empty
|
||||||
// one included, is an error naming exp and the value.
|
// one included, is an error naming exp and the value.
|
||||||
func parseExpires(query url.Values) (time.Time, error) {
|
func parseExpires(query url.Values) (time.Time, error) {
|
||||||
if !query.Has("exp") {
|
if !query.Has("exp") {
|
||||||
@@ -217,9 +226,13 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondImageError maps image retrieval errors to HTTP responses.
|
// respondImageError answers err, the image service's error for req, on both
|
||||||
|
// image routes. It logs a 500 at error level, as pixa's own failure. It logs
|
||||||
|
// the other answers at warn level, since the client, the upstream or a full
|
||||||
|
// limit caused them. The 415 for a source refused for its first bytes is not
|
||||||
|
// logged here, because the image service logs it.
|
||||||
func (s *Handlers) respondImageError(
|
func (s *Handlers) respondImageError(
|
||||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
w http.ResponseWriter, req *imagerequest.Request, err error,
|
||||||
) {
|
) {
|
||||||
// An upstream body whose first bytes are not those of the format its
|
// An upstream body whose first bytes are not those of the format its
|
||||||
// Content-Type names is the upstream's error, not pixa's: the image
|
// Content-Type names is the upstream's error, not pixa's: the image
|
||||||
@@ -233,59 +246,53 @@ func (s *Handlers) respondImageError(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Error("failed to get image",
|
var (
|
||||||
"host", req.SourceHost,
|
status int
|
||||||
"path", req.SourcePath,
|
message string
|
||||||
"error", err,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for specific error types
|
switch {
|
||||||
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
status, message = http.StatusForbidden, "forbidden"
|
||||||
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
||||||
return
|
status, message = http.StatusBadGateway, "upstream error"
|
||||||
}
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
|
status, message = http.StatusGatewayTimeout, "upstream timeout"
|
||||||
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
errors.Is(err, imageprocessor.ErrTooManyImages),
|
||||||
|
errors.Is(err, imageproxy.ErrProcessingTimedOut):
|
||||||
return
|
status = http.StatusServiceUnavailable
|
||||||
}
|
message = "server busy, try again later"
|
||||||
|
case errors.Is(err, httpfetcher.ErrInvalidContentType),
|
||||||
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages) ||
|
status = http.StatusUnsupportedMediaType
|
||||||
errors.Is(err, imgcache.ErrProcessingTimedOut) {
|
message = "unsupported source format"
|
||||||
s.respondError(w, "server busy, try again later",
|
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
||||||
http.StatusServiceUnavailable)
|
status = http.StatusBadRequest
|
||||||
|
message = fmt.Sprintf("size too large for the source: the side "+
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
|
|
||||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
|
|
||||||
s.respondError(w, "unsupported source format",
|
|
||||||
http.StatusUnsupportedMediaType)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
|
|
||||||
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
|
||||||
"computed from its proportions is above %d",
|
"computed from its proportions is above %d",
|
||||||
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
imageprocessor.MaxOutputDimension)
|
||||||
|
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
message = fmt.Sprintf("size too large for AVIF: the output "+
|
||||||
|
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
||||||
|
default:
|
||||||
|
s.log.Error("failed to get image",
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge) {
|
s.log.Warn("failed to get image",
|
||||||
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
"host", req.Source.Host,
|
||||||
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
"path", req.Source.Path,
|
||||||
http.StatusBadRequest)
|
"error", err,
|
||||||
|
)
|
||||||
return
|
s.respondError(w, message, status)
|
||||||
}
|
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cacheControl returns the Cache-Control header for an image served through a
|
// cacheControl returns the Cache-Control header for an image served through a
|
||||||
@@ -342,13 +349,50 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeImageResponse writes headers and streams the image content,
|
// serveImage serves the image that req asks for. Both image routes call it
|
||||||
// handling conditional and HEAD requests.
|
// once they have the request and its URL's expiry, which ends the
|
||||||
func (s *Handlers) writeImageResponse(
|
// Cache-Control max-age. It chooses the format for the format auto, answers
|
||||||
|
// If-None-Match with 304 and HEAD with the headers only. It answers 499 when
|
||||||
|
// the image service stopped because the client went away, and any other error
|
||||||
|
// of the image service with respondImageError.
|
||||||
|
func (s *Handlers) serveImage(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
req *imagerequest.Request, expires time.Time,
|
||||||
cacheKey imgcache.VariantKey, startTime time.Time,
|
|
||||||
) {
|
) {
|
||||||
|
// The signature or the token covers the format auto, not the format chosen
|
||||||
|
format, ok := s.chooseAutoFormat(w, r, req.Transform.Format)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Transform.Format = format
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
resp, err := s.imgSvc.Get(r.Context(), req)
|
||||||
|
|
||||||
|
// The client went away before its image was ready, which is not pixa's
|
||||||
|
// failure. A cancellation while the request's context is live is pixa's,
|
||||||
|
// and respondImageError answers it 500.
|
||||||
|
if errors.Is(err, context.Canceled) && r.Context().Err() != nil {
|
||||||
|
s.log.Info("client went away before the image was ready",
|
||||||
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
)
|
||||||
|
w.WriteHeader(statusClientClosedRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.respondImageError(w, req, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -357,7 +401,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", cacheControl(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) {
|
if notModified(w, r, resp.ETag) {
|
||||||
@@ -374,21 +418,31 @@ func (s *Handlers) writeImageResponse(
|
|||||||
// Stream the response
|
// Stream the response
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// A failed write is most often a client that has gone away, not pixa's
|
||||||
|
// failure, so it is logged at warn level
|
||||||
servedBytes, err := io.Copy(w, resp.Content)
|
servedBytes, err := io.Copy(w, resp.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to write response",
|
s.log.Warn("failed to write response",
|
||||||
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"served_bytes", servedBytes,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()),
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
"cache_key", cacheKey,
|
"cache_key", imagerequest.CacheKey(req),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
"cache_status", resp.CacheStatus,
|
"cache_status", resp.CacheStatus,
|
||||||
"duration_ms", duration.Milliseconds(),
|
"duration_ms", duration.Milliseconds(),
|
||||||
"format", req.Format,
|
"format", req.Transform.Format,
|
||||||
"served_bytes", servedBytes,
|
"served_bytes", servedBytes,
|
||||||
"fetched_bytes", resp.FetchedBytes,
|
"fetched_bytes", resp.FetchedBytes,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// photoPath is the path of the JPEG that newSignedHostServer serves.
|
// photoPath is the path of the JPEG that newSignedHostServer serves.
|
||||||
@@ -29,37 +36,46 @@ func newSignedHostServer(
|
|||||||
) (*Handlers, http.Handler) {
|
) (*Handlers, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||||
|
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(fstest.MapFS{
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
||||||
}),
|
}),
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Logger: log,
|
MaxInputBytes: 50 << 20,
|
||||||
})
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
Logger: log,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
t.Fatalf("imageproxy.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: log,
|
log: log,
|
||||||
imgSvc: svc,
|
imgSvc: svc,
|
||||||
encGen: encGen,
|
encGen: encGen,
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -105,17 +121,18 @@ func getMaxAge(t *testing.T, srv http.Handler, target string) int {
|
|||||||
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxAge := getMaxAge(t, srv, signedURL)
|
maxAge := getMaxAge(t, srv, signedURL)
|
||||||
if maxAge < 50 || maxAge > 60 {
|
if maxAge < 50 || maxAge > 60 {
|
||||||
@@ -168,13 +185,13 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
expiresAt int64
|
expires time.Time
|
||||||
wantAtLeast int
|
wantAtLeast int
|
||||||
wantAtMost int
|
wantAtMost int
|
||||||
}{
|
}{
|
||||||
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
|
{"60 second TTL", time.Now().Add(time.Minute), 50, 60},
|
||||||
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
|
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour), 31536000, 31536000},
|
||||||
{"no TTL", 0, 31536000, 31536000},
|
{"no TTL", time.Time{}, 31536000, 31536000},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -183,14 +200,16 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
ExpiresAt: tt.expiresAt,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
})
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, tt.expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
@@ -23,29 +33,38 @@ import (
|
|||||||
const allowlistedHost = "allowed.example.com"
|
const allowlistedHost = "allowed.example.com"
|
||||||
|
|
||||||
// newImageRoute returns the image route of a Handlers whose service fetches
|
// newImageRoute returns the image route of a Handlers whose service fetches
|
||||||
// with fetcher and checks signatures with testSigningKey.
|
// with fetcher, and which checks signatures with testSigningKey.
|
||||||
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{allowlistedHost},
|
MaxInputBytes: 50 << 20,
|
||||||
})
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create proxy: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{
|
||||||
|
imgSvc: svc,
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{allowlistedHost}),
|
||||||
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", h.HandleImage())
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
@@ -54,7 +73,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
||||||
// each of hosts, and answers any other URL with an upstream error.
|
// each of hosts, and answers any other source with an upstream error.
|
||||||
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -81,19 +100,27 @@ func photoURLWithSig(host, sig string, expires time.Time) string {
|
|||||||
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// photoRequest returns the image request of photoURL(host) at the default
|
||||||
|
// quality and fit.
|
||||||
|
func photoRequest(host string) *imagerequest.Request {
|
||||||
|
return &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: host,
|
||||||
|
Path: photoPath,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// photoSignature returns the signature of photoURL(host) at the default
|
// photoSignature returns the signature of photoURL(host) at the default
|
||||||
// quality and fit, made with key and expiring at expires.
|
// quality and fit, made with key and expiring at expires.
|
||||||
func photoSignature(key, host string, expires time.Time) string {
|
func photoSignature(key, host string, expires time.Time) string {
|
||||||
return signature.New(key).Sign(&signature.Request{
|
return signature.New(key).Sign(photoRequest(host), expires)
|
||||||
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.
|
// sendGet sends a GET for target to route and returns the response.
|
||||||
@@ -164,7 +191,12 @@ func TestHandleImage_ErrorAnswers(t *testing.T) {
|
|||||||
photos := newPhotoFetcher(t,
|
photos := newPhotoFetcher(t,
|
||||||
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
||||||
// The real fetcher refuses localhost before any lookup or connection.
|
// The real fetcher refuses localhost before any lookup or connection.
|
||||||
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
realFetcher := httpfetcher.New(&httpfetcher.Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
})
|
||||||
|
|
||||||
exp := time.Now().Add(time.Hour)
|
exp := time.Now().Add(time.Hour)
|
||||||
expired := time.Now().Add(-time.Hour)
|
expired := time.Now().Add(-time.Hour)
|
||||||
@@ -269,10 +301,9 @@ func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
|||||||
// host can be, and checks that each is refused with 400 naming the host as
|
// host can be, and checks that each is refused with 400 naming the host as
|
||||||
// decoded: allowlistedHost with a character after it that a URL host cannot
|
// decoded: allowlistedHost with a character after it that a URL host cannot
|
||||||
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
||||||
// around something that is not an IPv6 address. A "?" or "#" after
|
// around something that is not an IPv6 address. Later steps put the host
|
||||||
// allowlistedHost would otherwise end the host when the source URL is read,
|
// unparsed into the allowlist check, the cache key and the upstream URL, so
|
||||||
// so the request would pass the allowlist and be fetched and cached under a
|
// such a host is refused first.
|
||||||
// host that is not the one fetched.
|
|
||||||
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -343,26 +374,27 @@ func TestHandleImage_HostForms_Served(t *testing.T) {
|
|||||||
|
|
||||||
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
||||||
// source path or source query holds a "#", written as %23, and checks that
|
// source path or source query holds a "#", written as %23, and checks that
|
||||||
// each is refused with 400, the one signed for that source path included. The
|
// each is refused with 400, the one signed for that source path included.
|
||||||
// source URL would otherwise end at the "#" when it is fetched, so each text
|
// A "#" starts a URL's fragment, so neither the source path nor the source
|
||||||
// after it would fetch the image at photoPath and cache it under keys of its
|
// query may hold one.
|
||||||
// own.
|
|
||||||
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
||||||
exp := time.Now().Add(time.Hour)
|
exp := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath + "#x",
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath + "#x",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
Quality: 85,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJPEG,
|
||||||
Expires: exp,
|
Quality: 85,
|
||||||
})
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}, exp)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -388,3 +420,168 @@ func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// failingFetcher answers every fetch with its error.
|
||||||
|
type failingFetcher struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch returns f's error.
|
||||||
|
func (f failingFetcher) Fetch(
|
||||||
|
context.Context, imagerequest.Source, string, string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoURLsByRoute returns, for each image route, the function that makes the
|
||||||
|
// URL of the JPEG at photoPath on signedHost on that route.
|
||||||
|
func photoURLsByRoute() map[string]func(*testing.T, *Handlers) string {
|
||||||
|
return map[string]func(*testing.T, *Handlers) string{
|
||||||
|
"/v1/image/": signedPhotoURL,
|
||||||
|
"/v1/e/": encPhotoURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_SameErrorAnswers checks that both image routes answer each
|
||||||
|
// upstream fetch error below with the same status. They log it as "failed to
|
||||||
|
// get image" at the same level: error for the 500 and warn for the others.
|
||||||
|
// Each request gets routes of its own, so no request finds another's failed
|
||||||
|
// fetch in the negative cache.
|
||||||
|
func TestImageRoutes_SameErrorAnswers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
wantLevel slog.Level
|
||||||
|
}{
|
||||||
|
{"blocked upstream address", httpfetcher.ErrSSRFBlocked,
|
||||||
|
http.StatusForbidden, slog.LevelWarn},
|
||||||
|
{"upstream error status",
|
||||||
|
fmt.Errorf("%w: status 404", httpfetcher.ErrUpstreamError),
|
||||||
|
http.StatusBadGateway, slog.LevelWarn},
|
||||||
|
{"no response headers within the fetch timeout",
|
||||||
|
httpfetcher.ErrUpstreamTimeout, http.StatusGatewayTimeout,
|
||||||
|
slog.LevelWarn},
|
||||||
|
{"no free upstream connection", httpfetcher.ErrTooManyConnections,
|
||||||
|
http.StatusServiceUnavailable, slog.LevelWarn},
|
||||||
|
{"Content-Type not a source format",
|
||||||
|
fmt.Errorf("%w: image/svg+xml", httpfetcher.ErrInvalidContentType),
|
||||||
|
http.StatusUnsupportedMediaType, slog.LevelWarn},
|
||||||
|
{"any other failure", io.ErrUnexpectedEOF,
|
||||||
|
http.StatusInternalServerError, slog.LevelError},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for route, imageURL := range photoURLsByRoute() {
|
||||||
|
t.Run(route+" "+tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
h, routes := newRoutesFetchingThrough(t,
|
||||||
|
failingFetcher{err: tt.err},
|
||||||
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||||
|
|
||||||
|
rec := sendGet(t, routes, imageURL(t, h))
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d: %s",
|
||||||
|
rec.Code, tt.wantStatus, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
levels := loggedLevels(t, &logs)
|
||||||
|
if !slices.Contains(levels[tt.wantLevel.String()],
|
||||||
|
"failed to get image") {
|
||||||
|
t.Errorf(`no "failed to get image" line logged at level %s`,
|
||||||
|
tt.wantLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantLevel != slog.LevelError &&
|
||||||
|
len(levels[slog.LevelError.String()]) != 0 {
|
||||||
|
t.Errorf("logged at error level: %q",
|
||||||
|
levels[slog.LevelError.String()])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingResponseWriter is a response writer whose Write always fails, as when
|
||||||
|
// the client has gone away.
|
||||||
|
type failingResponseWriter struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes nothing and returns io.ErrClosedPipe.
|
||||||
|
func (failingResponseWriter) Write([]byte) (int, error) {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_FailedWriteLoggedOnce checks that both image routes log a
|
||||||
|
// failed write of the image once, at warn level, with the request's ID, the
|
||||||
|
// source host and path, the bytes served and the error. Neither may also log
|
||||||
|
// the image as served.
|
||||||
|
func TestImageRoutes_FailedWriteLoggedOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const requestID = "test-request-id"
|
||||||
|
|
||||||
|
for route, imageURL := range photoURLsByRoute() {
|
||||||
|
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)
|
||||||
|
srv.ServeHTTP(failingResponseWriter{httptest.NewRecorder()},
|
||||||
|
httptest.NewRequestWithContext(
|
||||||
|
ctx, http.MethodGet, imageURL(t, h), nil))
|
||||||
|
|
||||||
|
t.Logf("logged:\n%s", logs.String())
|
||||||
|
|
||||||
|
var failedWrites []map[string]any
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch line["msg"] {
|
||||||
|
case "image served":
|
||||||
|
t.Error(`"image served" logged after a failed write`)
|
||||||
|
case "failed to write response":
|
||||||
|
failedWrites = append(failedWrites, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failedWrites) != 1 {
|
||||||
|
t.Fatalf(`"failed to write response" logged %d times, want 1`,
|
||||||
|
len(failedWrites))
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]any{
|
||||||
|
"level": slog.LevelWarn.String(),
|
||||||
|
"request_id": requestID,
|
||||||
|
"host": signedHost,
|
||||||
|
"path": photoPath,
|
||||||
|
"served_bytes": float64(0),
|
||||||
|
"error": io.ErrClosedPipe.Error(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, wantValue := range want {
|
||||||
|
if got := failedWrites[0][key]; got != wantValue {
|
||||||
|
t.Errorf("%s = %v, want %v", key, got, wantValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +37,47 @@ func getImage(t *testing.T, fix *testFixtures, target string) int {
|
|||||||
return rec.Code
|
return rec.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateSignedURL returns the /v1/image/ URL of req, signed with key and
|
||||||
|
// expiring after ttl. It signs a copy of req, so req itself is not changed.
|
||||||
|
// An unset quality or fit in the copy is set to 85 or cover, the values the
|
||||||
|
// route uses when a URL has no q or fit. The URL carries q and fit next to sig
|
||||||
|
// and exp, so the route verifies it for the quality and fit it was signed
|
||||||
|
// with.
|
||||||
|
func generateSignedURL(
|
||||||
|
key string, req *imagerequest.Request, ttl time.Duration,
|
||||||
|
) string {
|
||||||
|
signed := *req
|
||||||
|
|
||||||
|
if signed.Transform.Quality == 0 {
|
||||||
|
signed.Transform.Quality = 85
|
||||||
|
}
|
||||||
|
|
||||||
|
if signed.Transform.FitMode == "" {
|
||||||
|
signed.Transform.FitMode = imagerequest.FitCover
|
||||||
|
}
|
||||||
|
|
||||||
|
expires := time.Now().Add(ttl)
|
||||||
|
sig := signature.New(key).Sign(&signed, expires)
|
||||||
|
|
||||||
|
// A source query follows the source path after a "?". The image route
|
||||||
|
// reads the path decoded once, so the whole path is written in Go's own
|
||||||
|
// percent-encoding of a URL path, which writes "?" as %3F and "%" as %25,
|
||||||
|
// and leaves "/", ";", ",", "=" and "&" as they are.
|
||||||
|
sourcePathAndQuery := signed.Source.Path
|
||||||
|
if signed.Source.Query != "" {
|
||||||
|
sourcePathAndQuery += "?" + signed.Source.Query
|
||||||
|
}
|
||||||
|
|
||||||
|
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%dx%d.%s",
|
||||||
|
signed.Source.Host, sourcePathAndQuery,
|
||||||
|
signed.Transform.Size.Width, signed.Transform.Size.Height,
|
||||||
|
signed.Transform.Format)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s?sig=%s&exp=%d&q=%d&fit=%s",
|
||||||
|
(&url.URL{Path: unescapedPath}).EscapedPath(), sig, expires.Unix(),
|
||||||
|
signed.Transform.Quality, signed.Transform.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
|
// 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
|
// 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.
|
// sends that signature with each q and fit below.
|
||||||
@@ -44,16 +86,18 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/images/photo.jpg",
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: "/images/photo.jpg",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
Quality: 85,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJPEG,
|
||||||
Expires: expires,
|
Quality: 85,
|
||||||
})
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}, expires)
|
||||||
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
||||||
signedHost, sig, expires.Unix())
|
signedHost, sig, expires.Unix())
|
||||||
|
|
||||||
@@ -80,17 +124,17 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by
|
||||||
// service's signed-URL generator to the route.
|
// generateSignedURL to the route.
|
||||||
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
quality int
|
quality int
|
||||||
fitMode imgcache.FitMode
|
fitMode imagerequest.Fit
|
||||||
}{
|
}{
|
||||||
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
{"quality 40 and fit contain", 40, imagerequest.FitContain},
|
||||||
{"quality and fit unset", 0, ""},
|
{"quality and fit unset", 0, ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,21 +142,21 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
||||||
|
&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: "/images/photo.jpg",
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: tt.quality,
|
||||||
|
FitMode: tt.fitMode,
|
||||||
|
},
|
||||||
|
}, time.Hour)
|
||||||
|
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
status := getImage(t, setupTestHandler(t), signedURL)
|
||||||
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 {
|
if status != http.StatusBadGateway {
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
@@ -171,10 +215,10 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
||||||
// by the service's signed-URL generator for source URLs with a query to the
|
// by generateSignedURL for source URLs with a query to the route, through a
|
||||||
// route, through a chi router: chi routes on the path as the client wrote it
|
// chi router: chi routes on the path as the client wrote it whenever that is
|
||||||
// whenever that is not Go's own encoding of it. A 502 means the signature
|
// not Go's own encoding of it. A 502 means the signature passed, so the route
|
||||||
// passed, so the route read the source path and query that were signed.
|
// read the source path and query that were signed.
|
||||||
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -194,20 +238,20 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
||||||
|
&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: photoPath,
|
||||||
|
Query: tt.query,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Hour)
|
||||||
|
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
status := getImage(t, setupTestHandler(t), signedURL)
|
||||||
SourceHost: signedHost,
|
|
||||||
SourcePath: photoPath,
|
|
||||||
SourceQuery: tt.query,
|
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
|
||||||
Format: imgcache.FormatJPEG,
|
|
||||||
}, time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := getImage(t, fix, signedURL)
|
|
||||||
if status != http.StatusBadGateway {
|
if status != http.StatusBadGateway {
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
@@ -224,17 +268,19 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
|
Path: photoPath,
|
||||||
Width: 50,
|
Query: "url=https://origin.example.com/my%20cat.jpg",
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
Quality: 85,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJPEG,
|
||||||
Expires: expires,
|
Quality: 85,
|
||||||
})
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}, expires)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -266,7 +312,7 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|||||||
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
||||||
// source query that each hold %25, the percent-escape of a literal "%", and
|
// source query that each hold %25, the percent-escape of a literal "%", and
|
||||||
// sends each to the route written as the README says, with that "%" written as
|
// sends each to the route written as the README says, with that "%" written as
|
||||||
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed.
|
// %25, and as generateSignedURL writes it. A 502 means the signature passed.
|
||||||
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -288,17 +334,19 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|||||||
|
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
signer := signature.New("test-signing-key-must-be-32-chars")
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
||||||
sig := signer.Sign(&signature.Request{
|
sig := signer.Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: tt.sourcePath,
|
Host: signedHost,
|
||||||
SourceQuery: tt.sourceQuery,
|
Path: tt.sourcePath,
|
||||||
Width: 50,
|
Query: tt.sourceQuery,
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJPEG),
|
Transform: imagerequest.Transform{
|
||||||
Quality: 85,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJPEG,
|
||||||
Expires: expires,
|
Quality: 85,
|
||||||
})
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}, expires)
|
||||||
|
|
||||||
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
||||||
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
||||||
@@ -309,23 +357,23 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) {
|
t.Run(tt.name+" written by generateSignedURL", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
||||||
|
&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: signedHost,
|
||||||
|
Path: tt.sourcePath,
|
||||||
|
Query: tt.sourceQuery,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Hour)
|
||||||
|
|
||||||
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
status := getImage(t, setupTestHandler(t), signedURL)
|
||||||
SourceHost: signedHost,
|
|
||||||
SourcePath: tt.sourcePath,
|
|
||||||
SourceQuery: tt.sourceQuery,
|
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
|
||||||
Format: imgcache.FormatJPEG,
|
|
||||||
}, time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := getImage(t, fix, signedURL)
|
|
||||||
if status != http.StatusBadGateway {
|
if status != http.StatusBadGateway {
|
||||||
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-126
@@ -2,20 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"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/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
||||||
@@ -27,159 +20,59 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
req, expires, ok := s.parseImageEncRequest(w, r)
|
||||||
start := time.Now()
|
if !ok {
|
||||||
|
|
||||||
req, ok := s.parseImageEncRequest(w, r)
|
|
||||||
if !ok || !s.chooseAutoFormat(w, r, req) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the request
|
s.serveImage(w, r, req, expires)
|
||||||
s.log.Debug("encrypted image request",
|
|
||||||
"host", req.SourceHost,
|
|
||||||
"path", req.SourcePath,
|
|
||||||
"dimensions", fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height),
|
|
||||||
"format", req.Format,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetch and process the image (no signature validation
|
|
||||||
// needed - encrypted URL is trusted)
|
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
s.handleImageError(w, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Content.Close() }()
|
|
||||||
|
|
||||||
// Set response headers
|
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache headers: max-age ends at the URL's expiry
|
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
|
||||||
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
|
|
||||||
written, err := io.Copy(w, resp.Content)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to write response", "error", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log completion
|
|
||||||
duration := time.Since(start)
|
|
||||||
s.log.Info("image served",
|
|
||||||
"request_id", middleware.GetReqID(ctx),
|
|
||||||
"cache_key", imgcache.CacheKey(req),
|
|
||||||
"host", req.SourceHost,
|
|
||||||
"path", req.SourcePath,
|
|
||||||
"format", req.Format,
|
|
||||||
"cache_status", resp.CacheStatus,
|
|
||||||
"served_bytes", written,
|
|
||||||
"duration_ms", duration.Milliseconds(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
||||||
// ImageRequest and checks it. On a token that is missing, does not decrypt,
|
// image request and checks it, and returns it with the token's expiry. On a
|
||||||
// has expired or asks for something not valid, it writes an error response
|
// token that is missing, does not decrypt, has expired or asks for something
|
||||||
// and returns false.
|
// not valid, it writes an error response and returns false.
|
||||||
func (s *Handlers) parseImageEncRequest(
|
func (s *Handlers) parseImageEncRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imgcache.ImageRequest, bool) {
|
) (*imagerequest.Request, time.Time, bool) {
|
||||||
// Extract token from URL
|
// Extract token from URL
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
s.respondError(w, "missing token", http.StatusBadRequest)
|
s.respondError(w, "missing token", http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and validate the payload
|
// Decrypt and validate the payload
|
||||||
payload, err := s.encGen.Parse(token)
|
req, expires, err := s.encGen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, encurl.ErrExpired) {
|
if errors.Is(err, encurl.ErrExpired) {
|
||||||
s.log.Debug("encrypted URL expired", "error", err)
|
s.log.Debug("encrypted URL expired", "error", err)
|
||||||
s.respondError(w, "URL has expired", http.StatusGone)
|
s.respondError(w, "URL has expired", http.StatusGone)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Debug("failed to decrypt URL", "error", err)
|
s.log.Debug("invalid encrypted URL", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert payload to ImageRequest
|
|
||||||
req := payload.ToImageRequest()
|
|
||||||
|
|
||||||
// Apply the same dimension and fit-mode bounds as the plain image
|
// Apply the same dimension and fit-mode bounds as the plain image
|
||||||
// route: a sealed payload is trusted for its origin, not for staying
|
// route: a sealed payload is trusted for its origin, not for staying
|
||||||
// within limits, so an over-limit size or unknown fit mode is a 400
|
// within limits. This refuses an over-limit size before the source is
|
||||||
// here rather than an out-of-memory or a 500 from the processor.
|
// fetched. An unknown fit mode is a 400 here rather than a 500 from the
|
||||||
err = imgcache.ValidateImageRequest(req)
|
// processor.
|
||||||
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Debug("encrypted URL failed validation", "error", err)
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, false
|
return nil, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, true
|
return req, expires, true
|
||||||
}
|
|
||||||
|
|
||||||
// handleImageError converts image service errors to HTTP responses.
|
|
||||||
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
|
||||||
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
|
||||||
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
|
||||||
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages),
|
|
||||||
errors.Is(err, imgcache.ErrProcessingTimedOut):
|
|
||||||
s.respondError(w, "server busy, try again later",
|
|
||||||
http.StatusServiceUnavailable)
|
|
||||||
case errors.Is(err, httpfetcher.ErrInvalidContentType),
|
|
||||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat),
|
|
||||||
errors.Is(err, magic.ErrMagicByteMismatch),
|
|
||||||
errors.Is(err, magic.ErrUnknownFormat),
|
|
||||||
errors.Is(err, magic.ErrNotEnoughData):
|
|
||||||
s.respondError(w, "unsupported source format",
|
|
||||||
http.StatusUnsupportedMediaType)
|
|
||||||
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
|
||||||
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
|
||||||
"computed from its proportions is above %d",
|
|
||||||
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
|
||||||
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
|
|
||||||
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
|
||||||
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
|
||||||
http.StatusBadRequest)
|
|
||||||
default:
|
|
||||||
s.log.Error("image request failed", "error", err)
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newEncTestServer builds a router serving the encrypted-URL route with a
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||||
@@ -20,11 +22,13 @@ import (
|
|||||||
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
encGen: encGen,
|
encGen: encGen,
|
||||||
@@ -54,12 +58,15 @@ func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: "cdn.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/photo.jpg",
|
Host: "cdn.example.com",
|
||||||
Width: 100000,
|
Path: "/photo.jpg",
|
||||||
Height: 100000,
|
},
|
||||||
})
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 100000, Height: 100000},
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -79,13 +86,16 @@ func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|||||||
|
|
||||||
encGen, srv := newEncTestServer(t)
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
token, err := encGen.Generate(&encurl.Payload{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: "cdn.example.com",
|
Source: imagerequest.Source{
|
||||||
SourcePath: "/photo.jpg",
|
Host: "cdn.example.com",
|
||||||
Width: 800,
|
Path: "/photo.jpg",
|
||||||
Height: 600,
|
},
|
||||||
FitMode: imgcache.FitMode("bogus"),
|
Transform: imagerequest.Transform{
|
||||||
})
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
|
FitMode: imagerequest.Fit("bogus"),
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
||||||
@@ -59,32 +61,34 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
photo := encurl.Payload{
|
photo := imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := h.encGen.Generate(&photo)
|
valid, err := h.encGen.Generate(&photo, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expiredPhoto := photo
|
expired, err := h.encGen.Generate(&photo, time.Now().Add(-time.Minute))
|
||||||
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
|
|
||||||
|
|
||||||
expired, err := h.encGen.Generate(&expiredPhoto)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
|
keys, err := seal.DeriveKeys("another-signing-key-fedcba9876543210")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
otherKey, err := otherGen.Generate(&photo)
|
otherGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
|
otherKey, err := otherGen.Generate(&photo, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/signature"
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,16 +58,18 @@ func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
|
|||||||
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
|
route := newImageRoute(t, newPhotoFetcher(t, signedHost))
|
||||||
expires := time.Now().Add(time.Hour)
|
expires := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: string(imgcache.FormatJXL),
|
Transform: imagerequest.Transform{
|
||||||
Quality: encurl.DefaultQuality,
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
FitMode: string(imgcache.FitCover),
|
Format: imagerequest.FormatJXL,
|
||||||
Expires: expires,
|
Quality: encurl.DefaultQuality,
|
||||||
})
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}, expires)
|
||||||
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
|
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
|
||||||
|
|
||||||
for _, size := range []string{"50x50.jxl", "50x50"} {
|
for _, size := range []string{"50x50.jxl", "50x50"} {
|
||||||
@@ -83,12 +85,15 @@ func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
})
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jxlType is the content type of JPEG XL.
|
// jxlType is the content type of JPEG XL.
|
||||||
@@ -22,14 +22,14 @@ func TestFormatForAccept_JPEGXL(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
accept string
|
accept string
|
||||||
want imgcache.ImageFormat
|
want imagerequest.Format
|
||||||
}{
|
}{
|
||||||
{"JPEG XL-capable browser",
|
{"JPEG XL-capable browser",
|
||||||
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
|
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imagerequest.FormatJXL},
|
||||||
{"JPEG XL only", jxlType, imgcache.FormatJXL},
|
{"JPEG XL only", jxlType, imagerequest.FormatJXL},
|
||||||
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
|
||||||
imgcache.FormatJXL},
|
imagerequest.FormatJXL},
|
||||||
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
|
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imagerequest.FormatAVIF},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"sneak.berlin/go/pixa/internal/allowlist"
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
|
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
|
||||||
@@ -29,11 +35,12 @@ type countingFetcher struct {
|
|||||||
|
|
||||||
// Fetch counts the fetch and passes it on.
|
// Fetch counts the fetch and passes it on.
|
||||||
func (f *countingFetcher) Fetch(
|
func (f *countingFetcher) Fetch(
|
||||||
ctx context.Context, url string,
|
ctx context.Context, source imagerequest.Source,
|
||||||
|
ifNoneMatch, ifModifiedSince string,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
f.fetches.Add(1)
|
f.fetches.Add(1)
|
||||||
|
|
||||||
return f.Fetcher.Fetch(ctx, url)
|
return f.Fetcher.Fetch(ctx, source, ifNoneMatch, ifModifiedSince)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRefererRoutes returns both image routes of a Handlers whose
|
// newRefererRoutes returns both image routes of a Handlers whose
|
||||||
@@ -47,33 +54,41 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
|
|||||||
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
|
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
|
||||||
}
|
}
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
MaxBytes: 1 << 30,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imageproxy.New(&imageproxy.Config{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
SigningKey: testSigningKey,
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
Allowlist: []string{allowlistedHost},
|
MaxInputBytes: 50 << 20,
|
||||||
})
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
}, prometheus.NewRegistry())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
t.Fatalf("imageproxy.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
imgSvc: svc,
|
imgSvc: svc,
|
||||||
encGen: encGen,
|
encGen: encGen,
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New([]string{allowlistedHost}),
|
||||||
refererBlocklist: allowlist.New(
|
refererBlocklist: allowlist.New(
|
||||||
[]string{"leech.example", ".hotlinker.example"}),
|
[]string{"leech.example", ".hotlinker.example"}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,26 +13,26 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
|
||||||
// JPEG at photoPath on signedHost at 50x50, made with h's image service.
|
// JPEG at photoPath on signedHost at 50x50. It is signed with testSigningKey,
|
||||||
func signedPhotoURL(t *testing.T, h *Handlers) string {
|
// the key the handlers that newSignedHostServer builds check signatures with.
|
||||||
|
// It takes a Handlers it does not use, so that it has the type of encPhotoURL.
|
||||||
|
func signedPhotoURL(t *testing.T, _ *Handlers) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
return generateSignedURL(testSigningKey, &imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Size: imgcache.Size{Width: 50, Height: 50},
|
Path: photoPath,
|
||||||
Format: imgcache.FormatJPEG,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return signedURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the
|
// encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the
|
||||||
@@ -40,13 +40,16 @@ func signedPhotoURL(t *testing.T, h *Handlers) string {
|
|||||||
func encPhotoURL(t *testing.T, h *Handlers) string {
|
func encPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&encurl.Payload{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
SourceHost: signedHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: photoPath,
|
Host: signedHost,
|
||||||
Width: 50,
|
Path: photoPath,
|
||||||
Height: 50,
|
},
|
||||||
Format: imgcache.FormatJPEG,
|
Transform: imagerequest.Transform{
|
||||||
})
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
},
|
||||||
|
}, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,77 +11,64 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestServerBusyAnswers503 checks that both image routes answer 503 with
|
// TestServerBusyAnswers503 checks that respondImageError, which answers the
|
||||||
// a clear error when the image service gives up waiting for a free upstream
|
// errors of both image routes, answers 503 with a clear error when the image
|
||||||
// connection or processing slot, wrapped as the service wraps them.
|
// service gives up waiting for a free upstream connection or processing slot,
|
||||||
|
// wrapped as the service wraps them.
|
||||||
func TestServerBusyAnswers503(t *testing.T) {
|
func TestServerBusyAnswers503(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"}
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.jpg"},
|
||||||
|
}
|
||||||
|
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
|
||||||
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
fmt.Errorf("image processing failed: %w", imageprocessor.ErrTooManyImages),
|
||||||
} {
|
} {
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, req, err)
|
h.respondImageError(rec, req, err)
|
||||||
|
|
||||||
encrypted := httptest.NewRecorder()
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
h.handleImageError(encrypted, err)
|
t.Errorf("for %v: status = %d, want %d",
|
||||||
|
err, rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
for route, rec := range map[string]*httptest.ResponseRecorder{
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
"/v1/image/": plain, "/v1/e/": encrypted,
|
t.Errorf("for %v: body = %q, want the server busy error",
|
||||||
} {
|
err, rec.Body.String())
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessingTimedOutAnswers503 checks that respondImageError and
|
// TestProcessingTimedOutAnswers503 checks that respondImageError, which
|
||||||
// handleImageError, which answer the errors of the two image routes, answer
|
// answers the errors of both image routes, answers 503 with the server busy
|
||||||
// 503 with the server busy error when the image service reports that
|
// error when the image service reports that processing stopped at the
|
||||||
// processing stopped at the deadline of the request doing it, wrapped as the
|
// deadline of the request doing it, wrapped as the service wraps it. A request
|
||||||
// service wraps it. A request that waited for that processing gets this error
|
// that waited for that processing gets this error while its own deadline may
|
||||||
// while its own deadline may have time left. The request's source only goes
|
// have time left. The request's source only goes into a log line, so it is
|
||||||
// into a log line, so it is left empty.
|
// left empty.
|
||||||
func TestProcessingTimedOutAnswers503(t *testing.T) {
|
func TestProcessingTimedOutAnswers503(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
err := fmt.Errorf("%w: failed to encode: %w",
|
err := fmt.Errorf("%w: failed to encode: %w",
|
||||||
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
|
imageproxy.ErrProcessingTimedOut, context.DeadlineExceeded)
|
||||||
|
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, &imgcache.ImageRequest{}, err)
|
h.respondImageError(rec, &imagerequest.Request{}, err)
|
||||||
|
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
|
||||||
|
|
||||||
encrypted := httptest.NewRecorder()
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
h.handleImageError(encrypted, err)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
for name, rec := range map[string]*httptest.ResponseRecorder{
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
"respondImageError": plain, "handleImageError": encrypted,
|
t.Errorf("body = %q, want the server busy error", rec.Body.String())
|
||||||
} {
|
|
||||||
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Errorf("%s: status = %d, want %d",
|
|
||||||
name, rec.Code, http.StatusServiceUnavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
|
||||||
t.Errorf("%s: body = %q, want the server busy error",
|
|
||||||
name, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,45 +10,40 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
|
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
|
||||||
// 415 with a clear error when the upstream answers with a content type pixa
|
// answers the errors of both image routes, answers 415 with a clear error when
|
||||||
// does not accept, such as SVG, and when orig is asked of a source in a
|
// the upstream answers with a content type pixa does not accept, such as SVG,
|
||||||
// format the processor cannot name, wrapped as the service wraps them.
|
// and when libvips does not read a source as the format its first bytes
|
||||||
|
// show, wrapped as the service wraps them.
|
||||||
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||||
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"}
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.svg"},
|
||||||
|
}
|
||||||
|
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
||||||
httpfetcher.ErrInvalidContentType),
|
httpfetcher.ErrInvalidContentType),
|
||||||
fmt.Errorf("image processing failed: %w: unknown",
|
fmt.Errorf("image processing failed: %w: libvips does not read the input as avif",
|
||||||
imageprocessor.ErrUnsupportedInputFormat),
|
imageprocessor.ErrUnsupportedInputFormat),
|
||||||
} {
|
} {
|
||||||
plain := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.respondImageError(plain, req, err)
|
h.respondImageError(rec, req, err)
|
||||||
|
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
encrypted := httptest.NewRecorder()
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
h.handleImageError(encrypted, err)
|
t.Errorf("for %v: status = %d, want %d",
|
||||||
|
err, rec.Code, http.StatusUnsupportedMediaType)
|
||||||
|
}
|
||||||
|
|
||||||
for route, rec := range map[string]*httptest.ResponseRecorder{
|
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||||
"HandleImage": plain, "HandleImageEnc": encrypted,
|
t.Errorf("for %v: body = %q, want the unsupported source format error",
|
||||||
} {
|
err, rec.Body.String())
|
||||||
t.Logf("%s for %v: %d %s", route, err, rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnsupportedMediaType {
|
|
||||||
t.Errorf("%s for %v: status = %d, want %d",
|
|
||||||
route, err, rec.Code, http.StatusUnsupportedMediaType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
|
||||||
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
|
|
||||||
route, err, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addresses the port tests below put on the allowlist.
|
||||||
|
const (
|
||||||
|
allowlistedIPv4 = "203.0.113.5"
|
||||||
|
allowlistedIPv6 = "2001:db8::1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSignatureChecker returns Handlers that check signatures with
|
||||||
|
// testSigningKey and have allowlistHosts on their allowlist, as New builds
|
||||||
|
// them from signing_key and allowlist_hosts.
|
||||||
|
func newSignatureChecker(allowlistHosts ...string) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
signer: signature.New(testSigningKey),
|
||||||
|
allowlist: allowlist.New(allowlistHosts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSignature_NonAllowlistedHost_NoSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker(allowlistedHost)
|
||||||
|
|
||||||
|
// Should fail validation - not allowlisted and no signature
|
||||||
|
err := h.validateSignature(photoRequest(signedHost), "", time.Time{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("validateSignature() expected error for non-allowlisted " +
|
||||||
|
"host without signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSignature_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker(allowlistedHost)
|
||||||
|
req := photoRequest(signedHost)
|
||||||
|
|
||||||
|
// Generate a valid signature
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
sig := signature.New(testSigningKey).Sign(req, expires)
|
||||||
|
|
||||||
|
// Should pass validation
|
||||||
|
err := h.validateSignature(req, sig, expires)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("validateSignature() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSignature_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker(allowlistedHost)
|
||||||
|
req := photoRequest(signedHost)
|
||||||
|
|
||||||
|
// Generate an expired signature
|
||||||
|
expires := time.Now().Add(-time.Hour)
|
||||||
|
sig := signature.New(testSigningKey).Sign(req, expires)
|
||||||
|
|
||||||
|
// Should fail validation
|
||||||
|
err := h.validateSignature(req, sig, expires)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("validateSignature() expected error for expired signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSignature_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker(allowlistedHost)
|
||||||
|
|
||||||
|
// Should fail validation
|
||||||
|
err := h.validateSignature(photoRequest(signedHost), "invalid-signature",
|
||||||
|
time.Now().Add(time.Hour))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("validateSignature() expected error for invalid signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateSignature_NoAllowlist checks that with no host on the
|
||||||
|
// allowlist, a request without a signature fails validation.
|
||||||
|
func TestValidateSignature_NoAllowlist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker()
|
||||||
|
|
||||||
|
err := h.validateSignature(photoRequest(signedHost), "", time.Time{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("validateSignature() expected error when no host is allowlisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateSignature_SignatureExactHostMatch verifies that
|
||||||
|
// validateSignature enforces exact host matching for signatures. A
|
||||||
|
// signature for one host must not verify for a different host, even
|
||||||
|
// if they share a domain suffix.
|
||||||
|
func TestValidateSignature_SignatureExactHostMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker()
|
||||||
|
|
||||||
|
// Sign a request for signedHost
|
||||||
|
signedReq := photoRequest(signedHost)
|
||||||
|
expires := time.Now().Add(time.Hour)
|
||||||
|
sig := signature.New(testSigningKey).Sign(signedReq, expires)
|
||||||
|
|
||||||
|
// The original request should pass validation
|
||||||
|
t.Run("exact host passes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := h.validateSignature(signedReq, sig, expires)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("validateSignature() exact host failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to reuse the signature with different hosts
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
}{
|
||||||
|
{"parent domain", "example.com"},
|
||||||
|
{"sibling subdomain", "images.example.com"},
|
||||||
|
{"deeper subdomain", "a." + signedHost},
|
||||||
|
{"evil suffix domain", signedHost + ".evil.com"},
|
||||||
|
{"prefixed host", "evil" + signedHost},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+" rejected", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := h.validateSignature(photoRequest(tt.host), sig, expires)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("validateSignature() should reject signature for "+
|
||||||
|
"host %q (signed for %q)", tt.host, signedHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateSignature_HostWithPortNeedsSignature verifies that a host
|
||||||
|
// followed by a port, or by a colon with no port, matches no allowlist entry,
|
||||||
|
// so that a request for it without a signature is refused, while the same
|
||||||
|
// host with no port is allowlisted.
|
||||||
|
func TestValidateSignature_HostWithPortNeedsSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker(allowlistedHost, allowlistedIPv4, allowlistedIPv6)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
allowlisted bool
|
||||||
|
}{
|
||||||
|
{allowlistedHost, true},
|
||||||
|
{allowlistedHost + ":8443", false},
|
||||||
|
{allowlistedHost + ":443", false},
|
||||||
|
{allowlistedHost + ":", false},
|
||||||
|
{allowlistedIPv4, true},
|
||||||
|
{allowlistedIPv4 + ":8080", false},
|
||||||
|
{"[" + allowlistedIPv6 + "]", true},
|
||||||
|
{"[" + allowlistedIPv6 + "]:8443", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := h.validateSignature(photoRequest(tt.host), "", time.Time{})
|
||||||
|
t.Logf("validateSignature() for host %q with no signature: %v",
|
||||||
|
tt.host, err)
|
||||||
|
|
||||||
|
if allowlisted := err == nil; allowlisted != tt.allowlisted {
|
||||||
|
t.Errorf("host %q served without a signature = %v, want %v",
|
||||||
|
tt.host, allowlisted, tt.allowlisted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateSignature_UnbracketedIPv6PortNeedsSignature verifies that an
|
||||||
|
// allowlisted IPv6 address written without brackets and followed by a port,
|
||||||
|
// or by a colon with no port, needs a signature. Go's URL type reads
|
||||||
|
// 2001:db8::1:8443 as the host 2001:db8::1 with the port 8443.
|
||||||
|
func TestValidateSignature_UnbracketedIPv6PortNeedsSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newSignatureChecker(allowlistedIPv6)
|
||||||
|
|
||||||
|
for _, host := range []string{allowlistedIPv6 + ":8443", allowlistedIPv6 + ":"} {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := h.validateSignature(photoRequest(host), "", time.Time{})
|
||||||
|
t.Logf("validateSignature() for host %q with no signature: %v",
|
||||||
|
host, err)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("host %q served without a signature, want refused",
|
||||||
|
host)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
||||||
@@ -70,12 +71,15 @@ func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
|||||||
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
transport := transportOf(t, New(&Config{
|
||||||
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
Timeout: 30 * time.Second,
|
||||||
// blocking it can only come from the operator-supplied list.
|
MaxResponseSize: 50 << 20,
|
||||||
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
transport := transportOf(t, New(cfg))
|
// 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.
|
||||||
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")},
|
||||||
|
}))
|
||||||
|
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"198.51.100.5:80", // operator-supplied range
|
"198.51.100.5:80", // operator-supplied range
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFetch_SendsConditionalHeadersAndHandlesNotModified checks that Fetch
|
||||||
|
// sends a non-empty ifNoneMatch and ifModifiedSince as request headers, that
|
||||||
|
// upstream's 304 to such a request is a result with status 304 and no
|
||||||
|
// content, and that a 304 to a request with neither is ErrUpstreamError.
|
||||||
|
func TestFetch_SendsConditionalHeadersAndHandlesNotModified(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
etag = `"abc"`
|
||||||
|
lastModified = "Mon, 02 Nov 2026 10:00:00 GMT"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
received http.Header
|
||||||
|
)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
received = r.Header.Clone()
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
f, _ := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
|
res, err := f.Fetch(testContext(t), upstreamSource("/image"), etag, lastModified)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("conditional Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Content)
|
||||||
|
_ = res.Content.Close()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
t.Logf("status = %d, body = %q, request headers = %v", res.StatusCode, body, received)
|
||||||
|
gotIfNoneMatch := received.Get("If-None-Match")
|
||||||
|
gotIfModifiedSince := received.Get("If-Modified-Since")
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil || len(body) != 0 {
|
||||||
|
t.Errorf("304 content = %q, error = %v; want empty", body, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusNotModified {
|
||||||
|
t.Errorf("StatusCode = %d, want 304", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotIfNoneMatch != etag || gotIfModifiedSince != lastModified {
|
||||||
|
t.Errorf("If-None-Match = %q, If-Modified-Since = %q; want %q and %q",
|
||||||
|
gotIfNoneMatch, gotIfModifiedSince, etag, lastModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(f.allHostsSemaphore); held != 0 {
|
||||||
|
t.Errorf("connection still held after the 304: %d held", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = f.Fetch(testContext(t), upstreamSource("/image"), "", "")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Errorf("unconditional Fetch() answered 304: error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
||||||
@@ -13,7 +14,12 @@ func TestNewUsesCheckedDialerWithoutDialContext(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
transport := transportOf(t, New(DefaultConfig()))
|
transport := transportOf(t, New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}))
|
||||||
|
|
||||||
addr := srv.Listener.Addr().String()
|
addr := srv.Listener.Addr().String()
|
||||||
|
|
||||||
@@ -32,21 +38,25 @@ func TestDialContextReplacesOnlyTheDialer(t *testing.T) {
|
|||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
f := New(&Config{
|
||||||
cfg.AllowHTTP = true
|
Timeout: 30 * time.Second,
|
||||||
cfg.DialContext = dialer.dialContext
|
MaxResponseSize: 50 << 20,
|
||||||
f := New(cfg)
|
AllowHTTP: true,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
DialContext: dialer.dialContext,
|
||||||
|
})
|
||||||
|
|
||||||
if body := fetchBody(t, f, "/image"); body != imagePayload {
|
if body := fetchBody(t, f, "/image"); body != imagePayload {
|
||||||
t.Errorf("body = %q, want %q", body, imagePayload)
|
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
|
_, err := f.Fetch(testContext(t), imageSourceOn("127.0.0.1"), "", "")
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
|
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
_, err = f.Fetch(testContext(t), upstreamSource("/redirect/private"), "", "")
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
|
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
||||||
@@ -29,9 +31,9 @@ const imagePayload = "fake-jpeg-bytes"
|
|||||||
// would mean SSRF protection let a forbidden target reach the transport.
|
// would mean SSRF protection let a forbidden target reach the transport.
|
||||||
var errUnexpectedDial = errors.New("unexpected dial target")
|
var errUnexpectedDial = errors.New("unexpected dial target")
|
||||||
|
|
||||||
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
// upstreamSource returns the source at path on the fake public host.
|
||||||
func upstreamURL(path string) string {
|
func upstreamSource(path string) imagerequest.Source {
|
||||||
return "http://" + testPublicHost + path
|
return imagerequest.Source{Host: testPublicHost, Path: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordingDialer records every address the transport asks it to dial and
|
// recordingDialer records every address the transport asks it to dial and
|
||||||
@@ -81,7 +83,7 @@ func startUpstream(t *testing.T) *httptest.Server {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
_, _ = io.WriteString(w, imagePayload)
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -120,7 +122,12 @@ func newServerFetcher(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = DefaultConfig()
|
cfg = &Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.AllowHTTP = true
|
cfg.AllowHTTP = true
|
||||||
@@ -153,7 +160,7 @@ func testContext(t *testing.T) context.Context {
|
|||||||
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
res, err := f.Fetch(testContext(t), upstreamSource(path), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch(%s) error = %v", path, err)
|
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||||
}
|
}
|
||||||
@@ -165,7 +172,7 @@ func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
|||||||
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
res, err := f.Fetch(testContext(t), upstreamSource(path), "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = res.Content.Close()
|
_ = res.Content.Close()
|
||||||
|
|
||||||
@@ -217,7 +224,7 @@ func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
|
|||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
f, dialer := newServerFetcher(t, srv, nil)
|
f, dialer := newServerFetcher(t, srv, nil)
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
_, err := f.Fetch(testContext(t), upstreamSource("/redirect/private"), "", "")
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
@@ -280,8 +287,12 @@ func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxResponseSize = 8
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 8,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -310,8 +321,12 @@ func fetchWithMaxResponseSize(
|
|||||||
srv := httptest.NewServer(handler)
|
srv := httptest.NewServer(handler)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxResponseSize = maxResponseSize
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: maxResponseSize,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -343,7 +358,7 @@ func TestFetchContentLengthBodyAtMaxResponseSize(t *testing.T) {
|
|||||||
|
|
||||||
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(tc.size))
|
w.Header().Set("Content-Length", strconv.Itoa(tc.size))
|
||||||
_, _ = io.WriteString(w, body)
|
_, _ = io.WriteString(w, body)
|
||||||
})
|
})
|
||||||
@@ -391,7 +406,7 @@ func TestFetchChunkedBodyAtMaxResponseSize(t *testing.T) {
|
|||||||
|
|
||||||
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
_, _ = io.WriteString(w, body)
|
_, _ = io.WriteString(w, body)
|
||||||
// With no Content-Length, flushing sends the body
|
// With no Content-Length, flushing sends the body
|
||||||
// chunked; its end is sent when the handler returns.
|
// chunked; its end is sent when the handler returns.
|
||||||
@@ -436,8 +451,12 @@ func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnectionsPerHost = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 1,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -466,8 +485,12 @@ func assertSlotReleasedByClose(
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnectionsPerHost = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 1,
|
||||||
|
MaxConnections: 64,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -524,7 +547,7 @@ func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
|
// The dial-time re-resolution in dialSSRFSafe is what closes the DNS
|
||||||
// rebinding window: even if validateURL saw a public answer earlier, the
|
// 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
|
// dialer independently re-checks the address it is about to connect to. A full
|
||||||
// rebinding simulation (a resolver returning public, then private) would mean
|
// rebinding simulation (a resolver returning public, then private) would mean
|
||||||
@@ -544,9 +567,9 @@ func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
|
|||||||
t.Run(addr, func(t *testing.T) {
|
t.Run(addr, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
|
_, err := dialSSRFSafe(context.Background(), "tcp", addr, nil)
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
|
t.Errorf("dialSSRFSafe(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -560,7 +583,7 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
|
_, err := dialSSRFSafe(ctx, "tcp", testPublicHost+":80", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected a dial error for an unreachable public target")
|
t.Fatal("expected a dial error for an unreachable public target")
|
||||||
}
|
}
|
||||||
@@ -569,3 +592,53 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
|||||||
t.Errorf("public target was SSRF-blocked: %v", err)
|
t.Errorf("public target was SSRF-blocked: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFetchSchemeFollowsAllowHTTP checks that the fetcher fetches a source
|
||||||
|
// over https, on port 443, unless AllowHTTP is set, and then over http, on
|
||||||
|
// port 80. The test server speaks plain HTTP, so only the fetch over http
|
||||||
|
// succeeds.
|
||||||
|
func TestFetchSchemeFollowsAllowHTTP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
allowHTTP bool
|
||||||
|
wantAddr string
|
||||||
|
}{
|
||||||
|
{false, net.JoinHostPort(testPublicHost, "443")},
|
||||||
|
{true, net.JoinHostPort(testPublicHost, "80")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(fmt.Sprintf("AllowHTTP %v", tt.allowHTTP), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := startUpstream(t)
|
||||||
|
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||||
|
|
||||||
|
f := New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
AllowHTTP: tt.allowHTTP,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
DialContext: dialer.dialContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
res, err := f.Fetch(testContext(t), upstreamSource("/image"), "", "")
|
||||||
|
if err == nil {
|
||||||
|
_ = res.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
dialed := dialer.dialedAddrs()
|
||||||
|
t.Logf("Fetch(): error %v, dialed %v", err, dialed)
|
||||||
|
|
||||||
|
if served := err == nil; served != tt.allowHTTP {
|
||||||
|
t.Errorf("served = %v, want %v", served, tt.allowHTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dialed) != 1 || dialed[0] != tt.wantAddr {
|
||||||
|
t.Errorf("dialed %v, want only %s", dialed, tt.wantAddr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,36 +20,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetcher configuration constants.
|
// Fetcher configuration constants.
|
||||||
const (
|
const (
|
||||||
DefaultFetchTimeout = 30 * time.Second
|
DefaultTLSTimeout = 10 * time.Second
|
||||||
DefaultMaxResponseSize = 50 << 20 // 50MB
|
DefaultMaxIdleConns = 100
|
||||||
DefaultTLSTimeout = 10 * time.Second
|
DefaultIdleConnTimeout = 90 * time.Second
|
||||||
DefaultMaxIdleConns = 100
|
DefaultMaxRedirects = 10
|
||||||
DefaultIdleConnTimeout = 90 * time.Second
|
|
||||||
DefaultMaxRedirects = 10
|
|
||||||
DefaultMaxConnectionsPerHost = 20
|
|
||||||
DefaultMaxConnections = 64
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// userAgent is the User-Agent header sent to upstream servers.
|
||||||
|
const userAgent = "pixa/1.0"
|
||||||
|
|
||||||
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
||||||
// MaxConnections fetches are already in progress.
|
// MaxConnections fetches are already in progress.
|
||||||
const ConnectionWaitTimeout = 10 * time.Second
|
const ConnectionWaitTimeout = 10 * time.Second
|
||||||
|
|
||||||
// MIME content types.
|
|
||||||
const (
|
|
||||||
contentTypeJPEG = "image/jpeg"
|
|
||||||
contentTypePNG = "image/png"
|
|
||||||
contentTypeGIF = "image/gif"
|
|
||||||
contentTypeWebP = "image/webp"
|
|
||||||
contentTypeAVIF = "image/avif"
|
|
||||||
contentTypeJXL = "image/jxl"
|
|
||||||
contentTypeSVG = "image/svg+xml"
|
|
||||||
contentTypeOctetStream = "application/octet-stream"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Loopback addresses blocked by SSRF protection.
|
// Loopback addresses blocked by SSRF protection.
|
||||||
const (
|
const (
|
||||||
localhostIPv4 = "127.0.0.1"
|
localhostIPv4 = "127.0.0.1"
|
||||||
@@ -92,8 +80,13 @@ var (
|
|||||||
|
|
||||||
// Fetcher retrieves content from upstream origins.
|
// Fetcher retrieves content from upstream origins.
|
||||||
type Fetcher interface {
|
type Fetcher interface {
|
||||||
// Fetch retrieves content from the given URL.
|
// Fetch retrieves the content of the source image. A non-empty
|
||||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
// ifNoneMatch or ifModifiedSince is sent as the request header of that
|
||||||
|
// name, and upstream may then answer 304 Not Modified with no content.
|
||||||
|
Fetch(
|
||||||
|
ctx context.Context, source imagerequest.Source,
|
||||||
|
ifNoneMatch, ifModifiedSince string,
|
||||||
|
) (*FetchResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchResult contains the result of fetching from upstream.
|
// FetchResult contains the result of fetching from upstream.
|
||||||
@@ -120,17 +113,29 @@ type FetchResult struct {
|
|||||||
TLSCipherSuite string
|
TLSCipherSuite string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds configuration for the upstream fetcher.
|
// allowedContentTypes returns the content types an upstream response may
|
||||||
|
// have, which Fetch also sends as its Accept header: the MIME types of the
|
||||||
|
// formats pixa reads.
|
||||||
|
func allowedContentTypes() []string {
|
||||||
|
return []string{
|
||||||
|
imagerequest.FormatJPEG.MIMEType(),
|
||||||
|
imagerequest.FormatPNG.MIMEType(),
|
||||||
|
imagerequest.FormatGIF.MIMEType(),
|
||||||
|
imagerequest.FormatWebP.MIMEType(),
|
||||||
|
imagerequest.FormatAVIF.MIMEType(),
|
||||||
|
imagerequest.FormatJXL.MIMEType(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds configuration for the upstream fetcher. Every field but
|
||||||
|
// AllowHTTP, BlockedNetworks and DialContext must be set.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Timeout for upstream requests.
|
// Timeout for upstream requests.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// MaxResponseSize is the maximum allowed response body size.
|
// MaxResponseSize is the maximum allowed response body size.
|
||||||
MaxResponseSize int64
|
MaxResponseSize int64
|
||||||
// UserAgent to send to upstream servers.
|
// AllowHTTP makes the fetcher fetch sources over http instead of https,
|
||||||
UserAgent string
|
// and follow redirects to http (for testing only).
|
||||||
// AllowedContentTypes is an allow list of MIME types to accept.
|
|
||||||
AllowedContentTypes []string
|
|
||||||
// AllowHTTP allows non-TLS connections (for testing only).
|
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
MaxConnectionsPerHost int
|
||||||
@@ -147,26 +152,6 @@ type Config struct {
|
|||||||
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
|
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,
|
|
||||||
contentTypeJXL,
|
|
||||||
},
|
|
||||||
AllowHTTP: false,
|
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
|
||||||
MaxConnections: DefaultMaxConnections,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
||||||
// per host and for all hosts together.
|
// per host and for all hosts together.
|
||||||
type HTTPFetcher struct {
|
type HTTPFetcher struct {
|
||||||
@@ -194,10 +179,6 @@ type hostSemaphore struct {
|
|||||||
|
|
||||||
// New creates a new HTTPFetcher with SSRF protection.
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
func New(config *Config) *HTTPFetcher {
|
func New(config *Config) *HTTPFetcher {
|
||||||
if config == nil {
|
|
||||||
config = DefaultConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unless config.DialContext replaces it, the transport connects with
|
// Unless config.DialContext replaces it, the transport connects with
|
||||||
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
// the SSRF-safe dialer, which re-resolves and re-checks at connect time
|
||||||
// (closing the DNS-rebinding window) against both the built-in ranges
|
// (closing the DNS-rebinding window) against both the built-in ranges
|
||||||
@@ -226,7 +207,7 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the redirect target
|
// Validate the redirect target
|
||||||
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
err := validateURL(req.Context(), req.URL, config.AllowHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("redirect blocked: %w", err)
|
return fmt.Errorf("redirect blocked: %w", err)
|
||||||
}
|
}
|
||||||
@@ -244,18 +225,26 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves content from the given URL with SSRF protection. When
|
// Fetch retrieves the source image with SSRF protection, over https, or over
|
||||||
// MaxConnections fetches are already in progress, it waits up to
|
// http when AllowHTTP is set. When MaxConnections fetches are already in
|
||||||
// ConnectionWaitTimeout for one to finish, then fails with
|
// progress, it waits up to ConnectionWaitTimeout for one to finish, then
|
||||||
// ErrTooManyConnections.
|
// fails with ErrTooManyConnections. A non-empty ifNoneMatch or
|
||||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
// ifModifiedSince is sent as the If-None-Match or If-Modified-Since header.
|
||||||
|
// Upstream's 304 Not Modified to such a request is a result with StatusCode
|
||||||
|
// 304 and empty Content; to any other request it is ErrUpstreamError.
|
||||||
|
func (f *HTTPFetcher) Fetch(
|
||||||
|
ctx context.Context, source imagerequest.Source,
|
||||||
|
ifNoneMatch, ifModifiedSince string,
|
||||||
|
) (*FetchResult, error) {
|
||||||
|
sourceURL := source.URL(f.config.AllowHTTP)
|
||||||
|
|
||||||
// Validate URL before making request
|
// Validate URL before making request
|
||||||
err := validateURL(ctx, url, f.config.AllowHTTP)
|
err := validateURL(ctx, sourceURL, f.config.AllowHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := f.acquireConnection(ctx, extractHost(url))
|
release, err := f.acquireConnection(ctx, source.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -269,25 +258,10 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
parsedURL, err := neturl.Parse(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &http.Request{
|
req := &http.Request{
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
URL: parsedURL,
|
URL: sourceURL,
|
||||||
Header: make(http.Header),
|
Header: requestHeaders(ctx, ifNoneMatch, ifModifiedSince),
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Use httptrace to capture connection details
|
||||||
@@ -316,6 +290,20 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The deferred release gives the connection back, as no body follows
|
||||||
|
conditional := ifNoneMatch != "" || ifModifiedSince != ""
|
||||||
|
if conditional && resp.StatusCode == http.StatusNotModified {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return &FetchResult{
|
||||||
|
Content: http.NoBody,
|
||||||
|
Headers: resp.Header,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
FetchDurationMs: fetchDuration.Milliseconds(),
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
|
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -328,6 +316,33 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestHeaders returns the headers of a fetch: User-Agent, Accept, the
|
||||||
|
// ID of the request the fetch serves, so the fetch can be found in the
|
||||||
|
// upstream host's logs, and a non-empty ifNoneMatch or ifModifiedSince.
|
||||||
|
func requestHeaders(
|
||||||
|
ctx context.Context, ifNoneMatch, ifModifiedSince string,
|
||||||
|
) http.Header {
|
||||||
|
header := make(http.Header)
|
||||||
|
|
||||||
|
header.Set("User-Agent", userAgent)
|
||||||
|
header.Set("Accept", strings.Join(allowedContentTypes(), ", "))
|
||||||
|
|
||||||
|
requestID := middleware.GetReqID(ctx)
|
||||||
|
if requestID != "" {
|
||||||
|
header.Set(middleware.RequestIDHeader, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ifNoneMatch != "" {
|
||||||
|
header.Set("If-None-Match", ifNoneMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ifModifiedSince != "" {
|
||||||
|
header.Set("If-Modified-Since", ifModifiedSince)
|
||||||
|
}
|
||||||
|
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
// acquireConnection takes a slot for host, then one of the slots shared by
|
// 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
|
// 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.
|
// comes first, so fetches queued for one busy host hold no shared slot.
|
||||||
@@ -460,7 +475,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|||||||
// Extract the MIME type without parameters
|
// Extract the MIME type without parameters
|
||||||
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
|
|
||||||
for _, allowed := range f.config.AllowedContentTypes {
|
for _, allowed := range allowedContentTypes() {
|
||||||
if strings.EqualFold(mediaType, allowed) {
|
if strings.EqualFold(mediaType, allowed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -470,19 +485,14 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||||
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) error {
|
||||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
if !allowHTTP && fetchURL.Scheme != "https" {
|
||||||
return ErrUnsupportedScheme
|
return ErrUnsupportedScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, err := neturl.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %w", ErrInvalidHost, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hostname drops any port, and the brackets of an IPv6 address whether
|
// Hostname drops any port, and the brackets of an IPv6 address whether
|
||||||
// or not a port follows them
|
// or not a port follows them
|
||||||
host := parsedURL.Hostname()
|
host := fetchURL.Hostname()
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return ErrInvalidHost
|
return ErrInvalidHost
|
||||||
}
|
}
|
||||||
@@ -508,25 +518,6 @@ func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
|||||||
return nil
|
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.
|
// isLocalhost checks if the host is localhost.
|
||||||
func isLocalhost(host string) bool {
|
func isLocalhost(host string) bool {
|
||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
@@ -639,13 +630,6 @@ func ipv4CompatibleToIPv4(ip net.IP) net.IP {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
||||||
// internal range or operator-supplied blocked prefix, closing the
|
// internal range or operator-supplied blocked prefix, closing the
|
||||||
// DNS-rebinding window at connect time.
|
// DNS-rebinding window at connect time.
|
||||||
|
|||||||
@@ -5,70 +5,34 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testHost is the hostname used by mock fetch tests.
|
// testHost is the hostname used by mock fetch tests.
|
||||||
const testHost = "example.com"
|
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) {
|
func TestIsAllowedContentType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := New(DefaultConfig())
|
f := New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
contentType string
|
contentType string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{contentTypeJPEG, true},
|
{imagerequest.FormatJPEG.MIMEType(), true},
|
||||||
{contentTypePNG, true},
|
{imagerequest.FormatPNG.MIMEType(), true},
|
||||||
{contentTypeWebP, true},
|
{imagerequest.FormatWebP.MIMEType(), true},
|
||||||
{"image/jpeg; charset=utf-8", true},
|
{"image/jpeg; charset=utf-8", true},
|
||||||
{"IMAGE/JPEG", true},
|
{"IMAGE/JPEG", true},
|
||||||
{"text/html", false},
|
{"text/html", false},
|
||||||
@@ -88,33 +52,6 @@ func TestIsAllowedContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestIsLocalhost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -227,7 +164,7 @@ func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
|
|||||||
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := validateURL(t.Context(), "http://example.com/path", false)
|
err := validateURL(t.Context(), &url.URL{Scheme: "http", Host: testHost}, false)
|
||||||
if !errors.Is(err, ErrUnsupportedScheme) {
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||||
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +174,8 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
// 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)
|
err := validateURL(t.Context(),
|
||||||
|
&url.URL{Scheme: "http", Host: "nonexistent.invalid"}, true)
|
||||||
// We expect a host resolution error, not ErrUnsupportedScheme.
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||||
if errors.Is(err, ErrUnsupportedScheme) {
|
if errors.Is(err, ErrUnsupportedScheme) {
|
||||||
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||||
@@ -247,7 +185,7 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
|||||||
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := validateURL(t.Context(), "https://localhost/path", false)
|
err := validateURL(t.Context(), &url.URL{Scheme: "https", Host: "localhost"}, false)
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +194,7 @@ func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
|||||||
func TestValidateURL_EmptyHost(t *testing.T) {
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := validateURL(t.Context(), "https:///path", false)
|
err := validateURL(t.Context(), &url.URL{Scheme: "https"}, false)
|
||||||
if !errors.Is(err, ErrInvalidHost) {
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||||
}
|
}
|
||||||
@@ -271,14 +209,15 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
|||||||
|
|
||||||
m := NewMock(mockFS)
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
result, err := m.Fetch(context.Background(),
|
||||||
|
imagerequest.Source{Host: testHost, Path: "/images/photo.jpg"}, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() error = %v", err)
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = result.Content.Close() }()
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
if result.ContentType != contentTypeJPEG {
|
if result.ContentType != imagerequest.FormatJPEG.MIMEType() {
|
||||||
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +241,8 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
|||||||
mockFS := fstest.MapFS{}
|
mockFS := fstest.MapFS{}
|
||||||
m := NewMock(mockFS)
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
_, err := m.Fetch(context.Background(),
|
||||||
|
imagerequest.Source{Host: testHost, Path: "/missing.jpg"}, "", "")
|
||||||
if !errors.Is(err, ErrUpstreamError) {
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
}
|
}
|
||||||
@@ -319,7 +259,7 @@ func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
_, err := m.Fetch(ctx, imagerequest.Source{Host: testHost, Path: "/photo.jpg"}, "", "")
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
@@ -332,13 +272,13 @@ func TestDetectContentTypeFromPath(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"foo/bar.jpg", contentTypeJPEG},
|
{"foo/bar.jpg", imagerequest.FormatJPEG.MIMEType()},
|
||||||
{"foo/bar.JPG", contentTypeJPEG},
|
{"foo/bar.JPG", imagerequest.FormatJPEG.MIMEType()},
|
||||||
{"foo/bar.jpeg", contentTypeJPEG},
|
{"foo/bar.jpeg", imagerequest.FormatJPEG.MIMEType()},
|
||||||
{"foo/bar.png", contentTypePNG},
|
{"foo/bar.png", imagerequest.FormatPNG.MIMEType()},
|
||||||
{"foo/bar.gif", contentTypeGIF},
|
{"foo/bar.gif", imagerequest.FormatGIF.MIMEType()},
|
||||||
{"foo/bar.webp", contentTypeWebP},
|
{"foo/bar.webp", imagerequest.FormatWebP.MIMEType()},
|
||||||
{"foo/bar.avif", contentTypeAVIF},
|
{"foo/bar.avif", imagerequest.FormatAVIF.MIMEType()},
|
||||||
{"foo/bar.svg", contentTypeSVG},
|
{"foo/bar.svg", contentTypeSVG},
|
||||||
{"foo/bar.bin", contentTypeOctetStream},
|
{"foo/bar.bin", contentTypeOctetStream},
|
||||||
{"foo/bar", contentTypeOctetStream},
|
{"foo/bar", contentTypeOctetStream},
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
package httpfetcher
|
package httpfetcher
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
||||||
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
|
// of type image/jxl, and that the mock fetcher serves a .jxl file as
|
||||||
// as image/jxl.
|
// image/jxl.
|
||||||
func TestJPEGXLContentType(t *testing.T) {
|
func TestJPEGXLContentType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const jxlType = "image/jxl"
|
const jxlType = "image/jxl"
|
||||||
|
|
||||||
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
|
f := New(&Config{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 64,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !f.isAllowedContentType(jxlType) {
|
||||||
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
|
t.Errorf("isAllowedContentType(%q) = false, want true", jxlType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,20 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
// imageSourceOn is the fake upstream's image route on host.
|
||||||
// port. Each port is a different host to the per-host limit, while the test
|
func imageSourceOn(host string) imagerequest.Source {
|
||||||
// dialer sends every port to the one test server.
|
return imagerequest.Source{Host: host, Path: "/image"}
|
||||||
func imageURLOnPort(port int) string {
|
|
||||||
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
|
||||||
"/image"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfigMaxConnections(t *testing.T) {
|
// imageSourceOnPort is the fake upstream's image route on testPublicHost at
|
||||||
t.Parallel()
|
// port. Each port is a different host to the per-host limit, while the test
|
||||||
|
// dialer sends every port to the one test server.
|
||||||
if got := DefaultConfig().MaxConnections; got != DefaultMaxConnections {
|
func imageSourceOnPort(port int) imagerequest.Source {
|
||||||
t.Errorf("MaxConnections = %d, want %d", got, DefaultMaxConnections)
|
return imageSourceOn(net.JoinHostPort(testPublicHost, strconv.Itoa(port)))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
||||||
@@ -37,18 +35,22 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 2
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 2,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
first, err := f.Fetch(testContext(t), imageSourceOnPort(81), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
second, err := f.Fetch(testContext(t), imageSourceOnPort(82), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second Fetch() error = %v", err)
|
t.Fatalf("second Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
|
_, err = f.Fetch(testContext(t), imageSourceOnPort(83), "", "")
|
||||||
if !errors.Is(err, ErrTooManyConnections) {
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
t.Fatalf("close first body: %v", err)
|
t.Fatalf("close first body: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
|
third, err := f.Fetch(testContext(t), imageSourceOnPort(83), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -94,12 +96,16 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 1,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
first, err := f.Fetch(testContext(t), imageSourceOnPort(81), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -109,7 +115,7 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(82))
|
_, err = f.Fetch(ctx, imageSourceOnPort(82), "", "")
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
||||||
}
|
}
|
||||||
@@ -134,7 +140,7 @@ func TestFetchRemovesIdleHostSemaphores(t *testing.T) {
|
|||||||
|
|
||||||
for i := range 100 {
|
for i := range 100 {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
res, err := f.Fetch(ctx, imageURLOnPort(1+i%50))
|
res, err := f.Fetch(ctx, imageSourceOnPort(1+i%50), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Fetch() error = %v", err)
|
t.Errorf("Fetch() error = %v", err)
|
||||||
|
|
||||||
@@ -163,19 +169,22 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 1
|
Timeout: 30 * time.Second,
|
||||||
cfg.MaxConnectionsPerHost = 1
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 1,
|
||||||
|
MaxConnections: 1,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
open, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
open, err := f.Fetch(testContext(t), imageSourceOnPort(81), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), imageURLOnPort(82))
|
_, err = f.Fetch(testContext(t), imageSourceOnPort(82), "", "")
|
||||||
if !errors.Is(err, ErrTooManyConnections) {
|
if !errors.Is(err, ErrTooManyConnections) {
|
||||||
t.Fatalf("Fetch() from another host: error = %v, "+
|
t.Fatalf("Fetch() from another host: error = %v, "+
|
||||||
"want ErrTooManyConnections", err)
|
"want ErrTooManyConnections", err)
|
||||||
@@ -184,7 +193,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(81))
|
_, err = f.Fetch(ctx, imageSourceOnPort(81), "", "")
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
t.Fatalf("Fetch() from the busy host: error = %v, "+
|
t.Fatalf("Fetch() from the busy host: error = %v, "+
|
||||||
"want context.DeadlineExceeded", err)
|
"want context.DeadlineExceeded", err)
|
||||||
@@ -196,7 +205,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
|
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err = f.Fetch(ctx, imageURLOnPort(83))
|
_, err = f.Fetch(ctx, imageSourceOnPort(83), "", "")
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
|
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
|
||||||
"want context.DeadlineExceeded", err)
|
"want context.DeadlineExceeded", err)
|
||||||
@@ -219,15 +228,15 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
url string
|
source imagerequest.Source
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
{"upstream answers 500", upstreamSource("/status/500"), ErrUpstreamError},
|
||||||
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
|
{"upstream sends HTML", upstreamSource("/html"), ErrInvalidContentType},
|
||||||
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
||||||
// dialer refuses every host but testPublicHost.
|
// dialer refuses every host but testPublicHost.
|
||||||
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
{"connecting fails", imageSourceOn("198.51.100.7"), errUnexpectedDial},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -236,13 +245,17 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{
|
||||||
cfg.MaxConnections = 1
|
Timeout: 30 * time.Second,
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
MaxConnectionsPerHost: 20,
|
||||||
|
MaxConnections: 1,
|
||||||
|
}
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), tc.url)
|
_, err := f.Fetch(testContext(t), tc.source, "", "")
|
||||||
if !errors.Is(err, tc.want) {
|
if !errors.Is(err, tc.want) {
|
||||||
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
|
||||||
|
|
||||||
// errEmptyURLPath is returned when a mock URL has no usable path.
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
var errEmptyURLPath = errors.New("empty URL path")
|
)
|
||||||
|
|
||||||
// MockFetcher implements Fetcher using an embedded filesystem.
|
// 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.
|
// The source with host example.com and path /images/photo.jpg maps to
|
||||||
|
// example.com/images/photo.jpg; the source query is ignored.
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
@@ -24,8 +24,11 @@ func NewMock(fsys fs.FS) *MockFetcher {
|
|||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves content from the mock filesystem.
|
// Fetch retrieves the source's content from the mock filesystem. It ignores
|
||||||
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
// ifNoneMatch and ifModifiedSince, and always sends the content.
|
||||||
|
func (m *MockFetcher) Fetch(
|
||||||
|
ctx context.Context, source imagerequest.Source, _, _ string,
|
||||||
|
) (*FetchResult, error) {
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -33,11 +36,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL to get filesystem path
|
path := source.Host + source.Path
|
||||||
path, err := urlToFSPath(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the file
|
// Open the file
|
||||||
f, err := m.fs.Open(path)
|
f, err := m.fs.Open(path)
|
||||||
@@ -68,31 +67,11 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// urlToFSPath converts a URL to a filesystem path.
|
// The content types the mock fetcher gives files in no format pixa reads.
|
||||||
// https://example.com/images/photo.jpg -> example.com/images/photo.jpg
|
const (
|
||||||
func urlToFSPath(rawURL string) (string, error) {
|
contentTypeSVG = "image/svg+xml"
|
||||||
// Strip scheme
|
contentTypeOctetStream = "application/octet-stream"
|
||||||
url := rawURL
|
)
|
||||||
if idx := strings.Index(url, "://"); idx != -1 {
|
|
||||||
url = url[idx+3:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove query string
|
|
||||||
if idx := strings.Index(url, "?"); idx != -1 {
|
|
||||||
url = url[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove fragment
|
|
||||||
if idx := strings.Index(url, "#"); idx != -1 {
|
|
||||||
url = url[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if url == "" {
|
|
||||||
return "", errEmptyURLPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectContentTypeFromPath returns the MIME type based on file extension.
|
// detectContentTypeFromPath returns the MIME type based on file extension.
|
||||||
func detectContentTypeFromPath(path string) string {
|
func detectContentTypeFromPath(path string) string {
|
||||||
@@ -100,17 +79,17 @@ 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 contentTypeJPEG
|
return imagerequest.FormatJPEG.MIMEType()
|
||||||
case strings.HasSuffix(path, ".png"):
|
case strings.HasSuffix(path, ".png"):
|
||||||
return contentTypePNG
|
return imagerequest.FormatPNG.MIMEType()
|
||||||
case strings.HasSuffix(path, ".gif"):
|
case strings.HasSuffix(path, ".gif"):
|
||||||
return contentTypeGIF
|
return imagerequest.FormatGIF.MIMEType()
|
||||||
case strings.HasSuffix(path, ".webp"):
|
case strings.HasSuffix(path, ".webp"):
|
||||||
return contentTypeWebP
|
return imagerequest.FormatWebP.MIMEType()
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return contentTypeAVIF
|
return imagerequest.FormatAVIF.MIMEType()
|
||||||
case strings.HasSuffix(path, ".jxl"):
|
case strings.HasSuffix(path, ".jxl"):
|
||||||
return contentTypeJXL
|
return imagerequest.FormatJXL.MIMEType()
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return contentTypeSVG
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFetchSendsRequestID verifies that a fetch sends the ID of the request
|
// TestFetchSendsRequestID verifies that a fetch sends the ID of the request
|
||||||
@@ -25,7 +26,7 @@ func TestFetchSendsRequestID(t *testing.T) {
|
|||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
received <- r.Header.Get("X-Request-Id")
|
received <- r.Header.Get("X-Request-Id")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
_, _ = io.WriteString(w, imagePayload)
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -34,7 +35,7 @@ func TestFetchSendsRequestID(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
|
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
|
||||||
|
|
||||||
res, err := f.Fetch(ctx, upstreamURL("/image"))
|
res, err := f.Fetch(ctx, upstreamSource("/image"), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() error = %v", err)
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
|
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
|
||||||
@@ -37,31 +39,43 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input []byte
|
input []byte
|
||||||
format Format
|
sourceFormat imagerequest.Format
|
||||||
|
format imagerequest.Format
|
||||||
width, height int
|
width, height int
|
||||||
fit FitMode
|
fit imagerequest.Fit
|
||||||
refused bool
|
refused bool
|
||||||
}{
|
}{
|
||||||
{"AVIF at the limit", cutOff, FormatAVIF, 4096, 4096, FitCover, false},
|
{"AVIF at the limit", cutOff, imagerequest.FormatPNG,
|
||||||
{"AVIF above the limit", cutOff, FormatAVIF, 4096, 4097, FitCover, true},
|
imagerequest.FormatAVIF, 4096, 4096, imagerequest.FitCover, false},
|
||||||
{"WebP above the AVIF limit", cutOff, FormatWebP, 4096, 4097, FitCover, false},
|
{"AVIF above the limit", cutOff, imagerequest.FormatPNG,
|
||||||
|
imagerequest.FormatAVIF, 4096, 4097, imagerequest.FitCover, true},
|
||||||
|
{"WebP above the AVIF limit", cutOff, imagerequest.FormatPNG,
|
||||||
|
imagerequest.FormatWebP, 4096, 4097, imagerequest.FitCover, false},
|
||||||
// FitInside does not enlarge, so the output stays 64x32
|
// FitInside does not enlarge, so the output stays 64x32
|
||||||
{"AVIF inside a larger size", cutOff, FormatAVIF, 8192, 8192, FitInside, false},
|
{"AVIF inside a larger size", cutOff, imagerequest.FormatPNG,
|
||||||
|
imagerequest.FormatAVIF, 8192, 8192, imagerequest.FitInside, false},
|
||||||
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
|
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
|
||||||
{"AVIF outside a smaller size", cutOff, FormatAVIF, 8192, 1, FitOutside, true},
|
{"AVIF outside a smaller size", cutOff, imagerequest.FormatPNG,
|
||||||
|
imagerequest.FormatAVIF, 8192, 1, imagerequest.FitOutside, true},
|
||||||
// With no size, the output keeps the source's 4097x4096
|
// With no size, the output keeps the source's 4097x4096
|
||||||
{"AVIF of the source's size", largeAVIF, FormatAVIF, 0, 0, FitCover, true},
|
{"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
||||||
{"orig of an AVIF source", largeAVIF, FormatOriginal, 0, 0, FitCover, true},
|
imagerequest.FormatAVIF, 0, 0, imagerequest.FitCover, true},
|
||||||
{"JPEG of the source's size", largeAVIF, FormatJPEG, 0, 0, FitCover, false},
|
{"orig of an AVIF source", largeAVIF, imagerequest.FormatAVIF,
|
||||||
|
imagerequest.FormatOriginal, 0, 0, imagerequest.FitCover, true},
|
||||||
|
{"JPEG of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
||||||
|
imagerequest.FormatJPEG, 0, 0, imagerequest.FitCover, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := New(Params{}).Process(t.Context(), bytes.NewReader(tt.input),
|
_, err := New(Params{
|
||||||
&Request{
|
MaxInputBytes: 50 << 20,
|
||||||
Size: Size{Width: tt.width, Height: tt.height},
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(), bytes.NewReader(tt.input), tt.sourceFormat,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
|
||||||
Format: tt.format,
|
Format: tt.format,
|
||||||
FitMode: tt.fit,
|
FitMode: tt.fit,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
|
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
|
||||||
@@ -17,13 +19,17 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"width at the maximum", 4096, 1, Size{Height: 2}, nil},
|
{"width at the maximum", 4096, 1,
|
||||||
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge},
|
imagerequest.Size{Height: 2}, nil},
|
||||||
{"height at the maximum", 1, 4096, Size{Width: 2}, nil},
|
{"width above the maximum", 4096, 1,
|
||||||
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge},
|
imagerequest.Size{Height: 3}, ErrOutputTooLarge},
|
||||||
|
{"height at the maximum", 1, 4096,
|
||||||
|
imagerequest.Size{Width: 2}, nil},
|
||||||
|
{"height above the maximum", 1, 4096,
|
||||||
|
imagerequest.Size{Width: 3}, ErrOutputTooLarge},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -32,9 +38,16 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
@@ -64,18 +77,21 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantWidth, wantHeight int
|
wantWidth, wantHeight int
|
||||||
}{
|
}{
|
||||||
{"8192x1 at 100x0", 8192, 1, Size{Width: 100}, 100, 1},
|
{"8192x1 at 100x0", 8192, 1, imagerequest.Size{Width: 100}, 100, 1},
|
||||||
{"8192x1 at 1300x0", 8192, 1, Size{Width: 1300}, 1300, 1},
|
{"8192x1 at 1300x0", 8192, 1, imagerequest.Size{Width: 1300}, 1300, 1},
|
||||||
{"8192x2 at 100x0", 8192, 2, Size{Width: 100}, 100, 1},
|
{"8192x2 at 100x0", 8192, 2, imagerequest.Size{Width: 100}, 100, 1},
|
||||||
{"1x8192 at 0x100", 1, 8192, Size{Height: 100}, 1, 100},
|
{"1x8192 at 0x100", 1, 8192, imagerequest.Size{Height: 100}, 1, 100},
|
||||||
{"1x8192 at 0x1300", 1, 8192, Size{Height: 1300}, 1, 1300},
|
{"1x8192 at 0x1300", 1, 8192, imagerequest.Size{Height: 1300}, 1, 1300},
|
||||||
{"2x8192 at 0x100", 2, 8192, Size{Height: 100}, 1, 100},
|
{"2x8192 at 0x100", 2, 8192, imagerequest.Size{Height: 100}, 1, 100},
|
||||||
}
|
}
|
||||||
|
|
||||||
fits := []FitMode{FitCover, FitContain, FitFill, FitInside, FitOutside}
|
fits := []imagerequest.Fit{
|
||||||
|
imagerequest.FitCover, imagerequest.FitContain, imagerequest.FitFill,
|
||||||
|
imagerequest.FitInside, imagerequest.FitOutside,
|
||||||
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
for _, fit := range fits {
|
for _, fit := range fits {
|
||||||
@@ -91,9 +107,16 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: fit})
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: fit,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,14 +126,14 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
|
|
||||||
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
|
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
|
||||||
if fit == FitOutside {
|
if fit == imagerequest.FitOutside {
|
||||||
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
|
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
|
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
|
||||||
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
|
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
|
||||||
FitOutside)
|
imagerequest.FitOutside)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// readerAfterDeadline is a Process input whose first Read waits until done
|
// readerAfterDeadline is a Process input whose first Read waits until done
|
||||||
@@ -84,9 +86,10 @@ func checkProcessStopsAtDeadline(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
result, err := proc.Process(ctx, input, smallJPEGRequest())
|
result, err := proc.Process(ctx, input, imagerequest.FormatJPEG,
|
||||||
|
smallJPEGTransform())
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made a %dx%d image after its deadline passed",
|
t.Logf("Process() made a %dx%d image after its deadline passed",
|
||||||
result.Width, result.Height)
|
result.Width, result.Height)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
|
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format
|
||||||
@@ -12,8 +14,12 @@ import (
|
|||||||
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := New(Params{}).Process(
|
_, err := New(Params{
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
|
||||||
|
imagerequest.FormatJPEG, &imagerequest.Transform{},
|
||||||
)
|
)
|
||||||
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
||||||
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// processedSize runs input through Process and returns the output's size in
|
// processedSize runs input, an image in sourceFormat, through Process and
|
||||||
// bytes.
|
// returns the output's size in bytes.
|
||||||
func processedSize(t *testing.T, input []byte, req *Request) int64 {
|
func processedSize(
|
||||||
|
t *testing.T, input []byte, sourceFormat imagerequest.Format,
|
||||||
|
transform *imagerequest.Transform,
|
||||||
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
|
result, err := New(Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(), bytes.NewReader(input), sourceFormat, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,7 +75,8 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
|
size := processedSize(t, encodePNG(t, flat), imagerequest.FormatPNG,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
const rawBytes = width * height * 3
|
const rawBytes = width * height * 3
|
||||||
if size > rawBytes/10 {
|
if size > rawBytes/10 {
|
||||||
@@ -85,7 +93,8 @@ func TestImageProcessor_WebPAtEffort2(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
|
size := processedSize(t, input, imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
StripMetadata: true,
|
StripMetadata: true,
|
||||||
@@ -126,7 +135,8 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
|||||||
|
|
||||||
input := encodePNG(t, source)
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
|
size := processedSize(t, input, imagerequest.FormatPNG,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
StripMetadata: true,
|
StripMetadata: true,
|
||||||
@@ -152,7 +162,8 @@ func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input, &Request{Format: FormatJXL, Quality: 85})
|
size := processedSize(t, input, imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
|
||||||
|
|
||||||
params := vips.NewJxlExportParams()
|
params := vips.NewJxlExportParams()
|
||||||
params.Quality = 85
|
params.Quality = 85
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
|
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
|
||||||
@@ -20,12 +22,12 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
}{
|
}{
|
||||||
{"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}},
|
{"8192x1 at 8192x8192", 8192, 1, imagerequest.Size{Width: 8192, Height: 8192}},
|
||||||
{"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}},
|
{"8192x1 at 1x8192", 8192, 1, imagerequest.Size{Width: 1, Height: 8192}},
|
||||||
{"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}},
|
{"1x8192 at 8192x8192", 1, 8192, imagerequest.Size{Width: 8192, Height: 8192}},
|
||||||
{"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}},
|
{"1x8192 at 8192x1", 1, 8192, imagerequest.Size{Width: 8192, Height: 1}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -34,9 +36,16 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -100,12 +109,15 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
|
|||||||
|
|
||||||
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{
|
MaxConcurrentProcessing: 1,
|
||||||
Size: Size{Width: 50, Height: 50},
|
}).Process(t.Context(),
|
||||||
Format: FormatPNG,
|
bytes.NewReader(input), imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
&imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatPNG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -208,14 +220,14 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
side, squareSide int
|
side, squareSide int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
}{
|
}{
|
||||||
{"16x16 at 100x60", 16, 8, Size{Width: 100, Height: 60}},
|
{"16x16 at 100x60", 16, 8, imagerequest.Size{Width: 100, Height: 60}},
|
||||||
{"16x16 at 60x100", 16, 8, Size{Width: 60, Height: 100}},
|
{"16x16 at 60x100", 16, 8, imagerequest.Size{Width: 60, Height: 100}},
|
||||||
{"10x10 at 100x35", 10, 2, Size{Width: 100, Height: 35}},
|
{"10x10 at 100x35", 10, 2, imagerequest.Size{Width: 100, Height: 35}},
|
||||||
{"10x10 at 35x100", 10, 2, Size{Width: 35, Height: 100}},
|
{"10x10 at 35x100", 10, 2, imagerequest.Size{Width: 35, Height: 100}},
|
||||||
{"64x64 at 300x100", 64, 16, Size{Width: 300, Height: 100}},
|
{"64x64 at 300x100", 64, 16, imagerequest.Size{Width: 300, Height: 100}},
|
||||||
{"64x64 at 100x300", 64, 16, Size{Width: 100, Height: 300}},
|
{"64x64 at 100x300", 64, 16, imagerequest.Size{Width: 100, Height: 300}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -224,9 +236,16 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|||||||
|
|
||||||
input := createSquarePNG(t, tt.side, tt.squareSide)
|
input := createSquarePNG(t, tt.side, tt.squareSide)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{Size: tt.size, Format: FormatPNG, FitMode: FitCover})
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatPNG,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatPNG,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
|
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
|
||||||
@@ -17,15 +19,20 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
// wantWidth and wantHeight are the output's, 0 for a refusal
|
// wantWidth and wantHeight are the output's, 0 for a refusal
|
||||||
wantWidth, wantHeight int
|
wantWidth, wantHeight int
|
||||||
}{
|
}{
|
||||||
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0},
|
{"width above the maximum", 3000, 600,
|
||||||
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0},
|
imagerequest.Size{Width: 1, Height: 8192}, 0, 0},
|
||||||
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048},
|
{"height above the maximum", 600, 3000,
|
||||||
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192},
|
imagerequest.Size{Width: 8192, Height: 1}, 0, 0},
|
||||||
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200},
|
{"width at the maximum", 1024, 256,
|
||||||
|
imagerequest.Size{Width: 1, Height: 2048}, 8192, 2048},
|
||||||
|
{"height at the maximum", 256, 1024,
|
||||||
|
imagerequest.Size{Width: 2048, Height: 1}, 2048, 8192},
|
||||||
|
{"within the maximum", 3000, 600,
|
||||||
|
imagerequest.Size{Width: 1, Height: 1200}, 6000, 1200},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -34,9 +41,16 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitOutside,
|
||||||
|
})
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||||
}
|
}
|
||||||
@@ -71,11 +85,11 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputWidth, inputHeight int
|
inputWidth, inputHeight int
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantWidth, wantHeight int
|
wantWidth, wantHeight int
|
||||||
}{
|
}{
|
||||||
{"49x8192 at 0x100", 49, 8192, Size{Height: 100}, 1, 167},
|
{"49x8192 at 0x100", 49, 8192, imagerequest.Size{Height: 100}, 1, 167},
|
||||||
{"49x49 at 1x1", 49, 49, Size{Width: 1, Height: 1}, 1, 1},
|
{"49x49 at 1x1", 49, 49, imagerequest.Size{Width: 1, Height: 1}, 1, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -84,9 +98,16 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input),
|
MaxInputBytes: 50 << 20,
|
||||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: tt.size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
FitMode: imagerequest.FitOutside,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
@@ -54,49 +54,6 @@ func CheckJPEGXLSupport() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format represents supported output image formats.
|
|
||||||
type Format string
|
|
||||||
|
|
||||||
// Supported image output formats.
|
|
||||||
const (
|
|
||||||
FormatOriginal Format = "orig"
|
|
||||||
FormatJPEG Format = "jpeg"
|
|
||||||
FormatPNG Format = "png"
|
|
||||||
FormatWebP Format = "webp"
|
|
||||||
FormatAVIF Format = "avif"
|
|
||||||
FormatJXL Format = "jxl"
|
|
||||||
FormatGIF Format = "gif"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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.
|
// Result contains the output of image processing.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
// Content is the processed image data.
|
// Content is the processed image data.
|
||||||
@@ -113,18 +70,12 @@ type Result struct {
|
|||||||
InputWidth int
|
InputWidth int
|
||||||
// InputHeight is the original image height before processing.
|
// InputHeight is the original image height before processing.
|
||||||
InputHeight int
|
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.
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
||||||
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
||||||
const MaxInputDimension = 8192
|
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.
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
||||||
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||||
|
|
||||||
@@ -133,8 +84,8 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
|||||||
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||||
|
|
||||||
// MaxOutputDimension is the maximum width or height of an output image, and
|
// MaxOutputDimension is the maximum width or height of an output image, and
|
||||||
// so of a requested size (imgcache.MaxDimension).
|
// so of a requested size (imagerequest.MaxDimension).
|
||||||
const MaxOutputDimension = 8192
|
const MaxOutputDimension = imagerequest.MaxDimension
|
||||||
|
|
||||||
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
// ErrOutputTooLarge is returned, before resizing, when the output would have a
|
||||||
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
// side above MaxOutputDimension: the side of a size with one side 0, computed
|
||||||
@@ -160,9 +111,10 @@ var ErrAVIFOutputTooLarge = errors.New("AVIF output has too many pixels")
|
|||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
||||||
// libvips reads the input's first bytes as another format than the source
|
// libvips reads the input's first bytes as another format than its source
|
||||||
// formats, or as none: for example an SVG whose first bytes pass the
|
// format, or as none. An SVG whose first bytes pass the first-bytes check for
|
||||||
// first-bytes check for AVIF.
|
// AVIF is one example. A source format other than the six pixa reads is also
|
||||||
|
// refused with it.
|
||||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||||
|
|
||||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
@@ -184,42 +136,30 @@ type ImageProcessor struct {
|
|||||||
processingWaitTimeout time.Duration
|
processingWaitTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Params holds configuration for creating an ImageProcessor.
|
// Params holds configuration for creating an ImageProcessor. Both fields
|
||||||
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
// must be at least 1.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// MaxInputBytes is the maximum allowed input size in bytes.
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
// If <= 0, DefaultMaxInputBytes is used.
|
|
||||||
MaxInputBytes int64
|
MaxInputBytes int64
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
// If <= 0, config.DefaultMaxConcurrentProcessing() is used.
|
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new image processor with the given parameters.
|
// New creates a new image processor with the given parameters.
|
||||||
// A zero-value Params{} uses sensible defaults.
|
|
||||||
func New(params Params) *ImageProcessor {
|
func New(params Params) *ImageProcessor {
|
||||||
initVips()
|
initVips()
|
||||||
|
|
||||||
maxInputBytes := params.MaxInputBytes
|
|
||||||
if maxInputBytes <= 0 {
|
|
||||||
maxInputBytes = DefaultMaxInputBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
|
||||||
if maxConcurrentProcessing <= 0 {
|
|
||||||
maxConcurrentProcessing = config.DefaultMaxConcurrentProcessing()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
maxInputBytes: maxInputBytes,
|
maxInputBytes: params.MaxInputBytes,
|
||||||
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
processingSemaphore: make(chan struct{}, params.MaxConcurrentProcessing),
|
||||||
processingWaitTimeout: ProcessingWaitTimeout,
|
processingWaitTimeout: ProcessingWaitTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request. When
|
// Process transforms the image read from input according to transform.
|
||||||
// MaxConcurrentProcessing images are already being processed, it waits up
|
// sourceFormat is the input's format, which magic detected from its first
|
||||||
// to ProcessingWaitTimeout for one to finish, then fails with
|
// bytes. When MaxConcurrentProcessing images are already being processed,
|
||||||
|
// it waits up to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
|
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
|
||||||
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
|
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
|
||||||
// no way to stop libvips once an encode has begun, so an encode keeps its
|
// no way to stop libvips once an encode has begun, so an encode keeps its
|
||||||
@@ -228,7 +168,8 @@ func New(params Params) *ImageProcessor {
|
|||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *Request,
|
sourceFormat imagerequest.Format,
|
||||||
|
transform *imagerequest.Transform,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
release, err := p.acquireSlot(ctx)
|
release, err := p.acquireSlot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,13 +183,9 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// govips picks the libvips loader from the input's first bytes with
|
// Input libvips would not load as sourceFormat is refused before it is
|
||||||
// DetermineImageType. Input whose first bytes it reads as no source
|
// decoded
|
||||||
// format, such as SVG, which librsvg would render, is refused before
|
err = checkInputFormat(data, sourceFormat)
|
||||||
// libvips reads it.
|
|
||||||
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
|
||||||
|
|
||||||
sourceFormat, err := p.formatFromString(inputFormat)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -277,35 +214,36 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine target dimensions; too large ones are refused before resizing
|
// Determine target dimensions; too large ones are refused before resizing
|
||||||
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
|
targetWidth, targetHeight, err := targetDimensions(transform.Size,
|
||||||
|
origWidth, origHeight)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// orig is the source's own format; encode refuses an empty format
|
// orig is the source's own format; encode refuses an empty format
|
||||||
outputFormat := req.Format
|
outputFormat := transform.Format
|
||||||
if outputFormat == FormatOriginal {
|
if outputFormat == imagerequest.FormatOriginal {
|
||||||
outputFormat = sourceFormat
|
outputFormat = sourceFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine output dimensions; too large ones are refused before
|
// Determine output dimensions; too large ones are refused before
|
||||||
// resizing, which with FitCover already decodes the input
|
// resizing, which with FitCover already decodes the input
|
||||||
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
||||||
targetWidth, targetHeight, req.FitMode, outputFormat)
|
targetWidth, targetHeight, transform.FitMode, outputFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize if needed
|
// Resize if needed
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
err := p.resize(img, outputWidth, outputHeight, req.FitMode)
|
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode to target format
|
// Encode to target format
|
||||||
output, err := p.encode(ctx, img, outputFormat, req.Quality)
|
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
}
|
}
|
||||||
@@ -313,12 +251,11 @@ func (p *ImageProcessor) Process(
|
|||||||
return &Result{
|
return &Result{
|
||||||
Content: io.NopCloser(bytes.NewReader(output)),
|
Content: io.NopCloser(bytes.NewReader(output)),
|
||||||
ContentLength: int64(len(output)),
|
ContentLength: int64(len(output)),
|
||||||
ContentType: FormatToMIME(outputFormat),
|
ContentType: outputFormat.MIMEType(),
|
||||||
Width: img.Width(),
|
Width: img.Width(),
|
||||||
Height: img.Height(),
|
Height: img.Height(),
|
||||||
InputWidth: origWidth,
|
InputWidth: origWidth,
|
||||||
InputHeight: origHeight,
|
InputHeight: origHeight,
|
||||||
InputFormat: inputFormat,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +265,9 @@ func (p *ImageProcessor) Process(
|
|||||||
// rounded down but kept at 1 or more, as for a thin image it would round
|
// rounded down but kept at 1 or more, as for a thin image it would round
|
||||||
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
|
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
|
||||||
// which is ErrOutputTooLarge.
|
// which is ErrOutputTooLarge.
|
||||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
func targetDimensions(
|
||||||
|
size imagerequest.Size, origWidth, origHeight int,
|
||||||
|
) (int, int, error) {
|
||||||
width, height := size.Width, size.Height
|
width, height := size.Width, size.Height
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -351,62 +290,6 @@ func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
|||||||
return width, height, nil
|
return width, height, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIME types for the supported image formats.
|
|
||||||
const (
|
|
||||||
mimeJPEG = "image/jpeg"
|
|
||||||
mimePNG = "image/png"
|
|
||||||
mimeGIF = "image/gif"
|
|
||||||
mimeWebP = "image/webp"
|
|
||||||
mimeAVIF = "image/avif"
|
|
||||||
mimeJXL = "image/jxl"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read.
|
|
||||||
func (p *ImageProcessor) SupportedInputFormats() []string {
|
|
||||||
return []string{
|
|
||||||
mimeJPEG,
|
|
||||||
mimePNG,
|
|
||||||
mimeGIF,
|
|
||||||
mimeWebP,
|
|
||||||
mimeAVIF,
|
|
||||||
mimeJXL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportedOutputFormats returns formats this processor can write.
|
|
||||||
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
|
||||||
return []Format{
|
|
||||||
FormatJPEG,
|
|
||||||
FormatPNG,
|
|
||||||
FormatGIF,
|
|
||||||
FormatWebP,
|
|
||||||
FormatAVIF,
|
|
||||||
FormatJXL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 FormatJXL:
|
|
||||||
return mimeJXL
|
|
||||||
case FormatOriginal:
|
|
||||||
return "application/octet-stream"
|
|
||||||
default:
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
// ends, and returns how many images were still being processed then. It
|
// 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
|
// waits by taking each slot in processingSemaphore as it frees up until it
|
||||||
@@ -475,27 +358,49 @@ func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string for a govips image type.
|
// checkInputFormat returns ErrUnsupportedInputFormat unless libvips would
|
||||||
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
// load data with the loader of sourceFormat. govips picks the libvips loader
|
||||||
switch imageType {
|
// from the input's first bytes with DetermineImageType. It refuses an SVG
|
||||||
case vips.ImageTypeJPEG:
|
// whose first bytes pass the first-bytes check for AVIF, which librsvg would
|
||||||
return "jpeg"
|
// otherwise render.
|
||||||
case vips.ImageTypePNG:
|
func checkInputFormat(data []byte, sourceFormat imagerequest.Format) error {
|
||||||
return "png"
|
// A format with no loader, such as orig or auto, has the type govips also
|
||||||
case vips.ImageTypeGIF:
|
// gives input it cannot read, which the check below would let through.
|
||||||
return "gif"
|
sourceType := vipsImageType(sourceFormat)
|
||||||
case vips.ImageTypeWEBP:
|
if sourceType == vips.ImageTypeUnknown {
|
||||||
return "webp"
|
return fmt.Errorf("%w: %q is not a source format",
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
ErrUnsupportedInputFormat, sourceFormat)
|
||||||
return string(FormatAVIF)
|
}
|
||||||
case vips.ImageTypeJXL:
|
|
||||||
return string(FormatJXL)
|
if vips.DetermineImageType(data) != sourceType {
|
||||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
return fmt.Errorf("%w: libvips does not read the input as %s",
|
||||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
ErrUnsupportedInputFormat, sourceFormat)
|
||||||
vips.ImageTypeJP2K:
|
}
|
||||||
return "unknown"
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipsImageType returns the govips image type whose libvips loader reads
|
||||||
|
// format. orig and auto stand for another format, so they have
|
||||||
|
// vips.ImageTypeUnknown, which govips has no loader for.
|
||||||
|
func vipsImageType(format imagerequest.Format) vips.ImageType {
|
||||||
|
switch format {
|
||||||
|
case imagerequest.FormatJPEG:
|
||||||
|
return vips.ImageTypeJPEG
|
||||||
|
case imagerequest.FormatPNG:
|
||||||
|
return vips.ImageTypePNG
|
||||||
|
case imagerequest.FormatGIF:
|
||||||
|
return vips.ImageTypeGIF
|
||||||
|
case imagerequest.FormatWebP:
|
||||||
|
return vips.ImageTypeWEBP
|
||||||
|
case imagerequest.FormatAVIF:
|
||||||
|
return vips.ImageTypeAVIF
|
||||||
|
case imagerequest.FormatJXL:
|
||||||
|
return vips.ImageTypeJXL
|
||||||
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
|
return vips.ImageTypeUnknown
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return vips.ImageTypeUnknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,17 +411,18 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
|||||||
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
|
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
|
||||||
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
|
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
|
||||||
func outputDimensions(
|
func outputDimensions(
|
||||||
imgWidth, imgHeight, width, height int, fit FitMode, format Format,
|
imgWidth, imgHeight, width, height int,
|
||||||
|
fit imagerequest.Fit, format imagerequest.Format,
|
||||||
) (int, int, error) {
|
) (int, int, error) {
|
||||||
scaleW := float64(width) / float64(imgWidth)
|
scaleW := float64(width) / float64(imgWidth)
|
||||||
scaleH := float64(height) / float64(imgHeight)
|
scaleH := float64(height) / float64(imgHeight)
|
||||||
outputWidth, outputHeight := width, height
|
outputWidth, outputHeight := width, height
|
||||||
|
|
||||||
switch fit {
|
switch fit {
|
||||||
case FitCover, FitFill, "":
|
case imagerequest.FitCover, imagerequest.FitFill, "":
|
||||||
// Cover crops, and fill stretches, to exactly the target dimensions
|
// Cover crops, and fill stretches, to exactly the target dimensions
|
||||||
|
|
||||||
case FitContain:
|
case imagerequest.FitContain:
|
||||||
// Fit within the target dimensions, maintaining aspect ratio. Scaled
|
// Fit within the target dimensions, maintaining aspect ratio. Scaled
|
||||||
// by the smaller scale, the short side of a thin image can round
|
// by the smaller scale, the short side of a thin image can round
|
||||||
// down to 0, which libvips refuses as a width, so each side is at
|
// down to 0, which libvips refuses as a width, so each side is at
|
||||||
@@ -525,13 +431,13 @@ func outputDimensions(
|
|||||||
outputWidth = max(1, int(float64(imgWidth)*scale))
|
outputWidth = max(1, int(float64(imgWidth)*scale))
|
||||||
outputHeight = max(1, int(float64(imgHeight)*scale))
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
case FitInside:
|
case imagerequest.FitInside:
|
||||||
// Same as contain, but only shrink
|
// Same as contain, but only shrink
|
||||||
scale := min(scaleW, scaleH, 1)
|
scale := min(scaleW, scaleH, 1)
|
||||||
outputWidth = max(1, int(float64(imgWidth)*scale))
|
outputWidth = max(1, int(float64(imgWidth)*scale))
|
||||||
outputHeight = max(1, int(float64(imgHeight)*scale))
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
case FitOutside:
|
case imagerequest.FitOutside:
|
||||||
// The smallest dimension fits, the other may exceed the target. A
|
// The smallest dimension fits, the other may exceed the target. A
|
||||||
// side scaled to 1 can come out just under 1 in floating point, as
|
// side scaled to 1 can come out just under 1 in floating point, as
|
||||||
// 49 times 1/49 does, and round down to 0, which libvips refuses as
|
// 49 times 1/49 does, and round down to 0, which libvips refuses as
|
||||||
@@ -541,7 +447,7 @@ func outputDimensions(
|
|||||||
outputHeight = max(1, int(float64(imgHeight)*scale))
|
outputHeight = max(1, int(float64(imgHeight)*scale))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0, 0, fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
return 0, 0, fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The side FitOutside scales past the target is held to the maximum too
|
// The side FitOutside scales past the target is held to the maximum too
|
||||||
@@ -550,7 +456,8 @@ func outputDimensions(
|
|||||||
outputWidth, outputHeight, MaxOutputDimension)
|
outputWidth, outputHeight, MaxOutputDimension)
|
||||||
}
|
}
|
||||||
|
|
||||||
if format == FormatAVIF && outputWidth*outputHeight > MaxAVIFOutputPixels {
|
if format == imagerequest.FormatAVIF &&
|
||||||
|
outputWidth*outputHeight > MaxAVIFOutputPixels {
|
||||||
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
|
||||||
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
|
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
|
||||||
}
|
}
|
||||||
@@ -561,18 +468,18 @@ func outputDimensions(
|
|||||||
// resize resizes the image to width x height, the dimensions
|
// resize resizes the image to width x height, the dimensions
|
||||||
// outputDimensions gives for the fit mode.
|
// outputDimensions gives for the fit mode.
|
||||||
func (p *ImageProcessor) resize(
|
func (p *ImageProcessor) resize(
|
||||||
img *vips.ImageRef, width, height int, fit FitMode,
|
img *vips.ImageRef, width, height int, fit imagerequest.Fit,
|
||||||
) error {
|
) error {
|
||||||
switch fit {
|
switch fit {
|
||||||
case FitCover, "":
|
case imagerequest.FitCover, "":
|
||||||
// Crop the centre and resize to fill exact dimensions (default)
|
// Crop the centre and resize to fill exact dimensions (default)
|
||||||
return cropCentreAndResize(img, width, height)
|
return cropCentreAndResize(img, width, height)
|
||||||
|
|
||||||
case FitFill:
|
case imagerequest.FitFill:
|
||||||
// Resize to exact dimensions (may distort)
|
// Resize to exact dimensions (may distort)
|
||||||
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||||
|
|
||||||
case FitInside:
|
case imagerequest.FitInside:
|
||||||
// outputDimensions keeps the dimensions of an image that already fits
|
// outputDimensions keeps the dimensions of an image that already fits
|
||||||
if img.Width() == width && img.Height() == height {
|
if img.Width() == width && img.Height() == height {
|
||||||
return nil
|
return nil
|
||||||
@@ -580,11 +487,11 @@ func (p *ImageProcessor) resize(
|
|||||||
|
|
||||||
return img.Thumbnail(width, height, vips.InterestingNone)
|
return img.Thumbnail(width, height, vips.InterestingNone)
|
||||||
|
|
||||||
case FitContain, FitOutside:
|
case imagerequest.FitContain, imagerequest.FitOutside:
|
||||||
return img.Thumbnail(width, height, vips.InterestingNone)
|
return img.Thumbnail(width, height, vips.InterestingNone)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
return fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +533,8 @@ const defaultQuality = 85
|
|||||||
// to stop libvips once an encode has begun, so when ctx has ended encode
|
// to stop libvips once an encode has begun, so when ctx has ended encode
|
||||||
// returns ctx's error without beginning one.
|
// returns ctx's error without beginning one.
|
||||||
func (p *ImageProcessor) encode(
|
func (p *ImageProcessor) encode(
|
||||||
ctx context.Context, img *vips.ImageRef, format Format, quality int,
|
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
|
||||||
|
quality int,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
err := ctx.Err()
|
err := ctx.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -649,25 +557,25 @@ func (p *ImageProcessor) encode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case imagerequest.FormatJPEG:
|
||||||
return exportJPEG(img, quality)
|
return exportJPEG(img, quality)
|
||||||
|
|
||||||
case FormatPNG:
|
case imagerequest.FormatPNG:
|
||||||
return exportPNG(img)
|
return exportPNG(img)
|
||||||
|
|
||||||
case FormatGIF:
|
case imagerequest.FormatGIF:
|
||||||
return exportGIF(img)
|
return exportGIF(img)
|
||||||
|
|
||||||
case FormatWebP:
|
case imagerequest.FormatWebP:
|
||||||
return exportWebP(img, quality)
|
return exportWebP(img, quality)
|
||||||
|
|
||||||
case FormatAVIF:
|
case imagerequest.FormatAVIF:
|
||||||
return exportAVIF(img, quality)
|
return exportAVIF(img, quality)
|
||||||
|
|
||||||
case FormatJXL:
|
case imagerequest.FormatJXL:
|
||||||
return exportJXL(img, quality)
|
return exportJXL(img, quality)
|
||||||
|
|
||||||
case FormatOriginal:
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -846,24 +754,3 @@ func jxlDistance(quality int) float64 {
|
|||||||
|
|
||||||
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatFromString converts a format string from detectFormat to Format. A
|
|
||||||
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
|
||||||
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
|
|
||||||
switch format {
|
|
||||||
case "jpeg":
|
|
||||||
return FormatJPEG, nil
|
|
||||||
case "png":
|
|
||||||
return FormatPNG, nil
|
|
||||||
case "gif":
|
|
||||||
return FormatGIF, nil
|
|
||||||
case "webp":
|
|
||||||
return FormatWebP, nil
|
|
||||||
case string(FormatAVIF):
|
|
||||||
return FormatAVIF, nil
|
|
||||||
case string(FormatJXL):
|
|
||||||
return FormatJXL, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -87,29 +88,29 @@ func isAVIF(data []byte) bool {
|
|||||||
|
|
||||||
brand := string(data[8:12])
|
brand := string(data[8:12])
|
||||||
|
|
||||||
return brand == string(FormatAVIF) || brand == "avis"
|
return brand == string(imagerequest.FormatAVIF) || brand == "avis"
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectMIME is a minimal magic-byte detector for test assertions.
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
func detectMIME(data []byte) string {
|
func detectMIME(data []byte) string {
|
||||||
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
return mimeJPEG
|
return imagerequest.FormatJPEG.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||||
return mimePNG
|
return imagerequest.FormatPNG.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
return mimeGIF
|
return imagerequest.FormatGIF.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
return mimeWebP
|
return imagerequest.FormatWebP.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if isAVIF(data) {
|
if isAVIF(data) {
|
||||||
return mimeAVIF
|
return imagerequest.FormatAVIF.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
@@ -118,19 +119,20 @@ func detectMIME(data []byte) string {
|
|||||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 400, Height: 300},
|
Size: imagerequest.Size{Width: 400, Height: 300},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +158,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != mimeJPEG {
|
if mime != imagerequest.FormatJPEG.MIMEType() {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,18 +166,19 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: imagerequest.Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,7 +191,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != mimePNG {
|
if mime != imagerequest.FormatPNG.MIMEType() {
|
||||||
t.Errorf("Output format = %v, want image/png", mime)
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,23 +199,24 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
// processAndCheckSize processes a test JPEG of the given input dimensions
|
// processAndCheckSize processes a test JPEG of the given input dimensions
|
||||||
// with the requested size and asserts the resulting dimensions.
|
// with the requested size and asserts the resulting dimensions.
|
||||||
func processAndCheckSize(
|
func processAndCheckSize(
|
||||||
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
|
t *testing.T, inputW, inputH int, size imagerequest.Size, wantW, wantH int,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, inputW, inputH)
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: size,
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -232,27 +236,28 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Width and height 0: keep original size
|
// Width and height 0: keep original size
|
||||||
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
|
processAndCheckSize(t, 640, 480, imagerequest.Size{Width: 0, Height: 0}, 640, 480)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
||||||
// Should result in 400x200 (maintaining aspect ratio)
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
input := createTestJPEG(t, 800, 400)
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 400, Height: 400},
|
Size: imagerequest.Size{Width: 400, Height: 400},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,7 +275,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
|||||||
|
|
||||||
// 800x600 image, request width=400 height=0
|
// 800x600 image, request width=400 height=0
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
|
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 400, Height: 0}, 400, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
@@ -278,24 +283,25 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
|||||||
|
|
||||||
// 800x600 image, request width=0 height=300
|
// 800x600 image, request width=0 height=300
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
|
processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 0, Height: 300}, 400, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestPNG(t, 400, 300)
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: imagerequest.Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatPNG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -311,22 +317,6 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -345,18 +335,19 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
input := createTestJPEG(t, tt.width, tt.height)
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
_, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Process() should reject oversized input images")
|
t.Error("Process() should reject oversized input images")
|
||||||
}
|
}
|
||||||
@@ -371,20 +362,21 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image at exactly MaxInputDimension - should be accepted
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
input := createTestJPEG(t, MaxInputDimension, 100)
|
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"Process() should accept images at MaxInputDimension, got error: %v",
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
@@ -397,22 +389,25 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
|
|
||||||
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
||||||
// given format and asserts the output MIME type and dimensions.
|
// given format and asserts the output MIME type and dimensions.
|
||||||
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
func encodeAndCheck(
|
||||||
|
t *testing.T, format imagerequest.Format, quality int, wantMIME string,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -443,13 +438,13 @@ func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
|||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
|
encodeAndCheck(t, imagerequest.FormatWebP, 80, imagerequest.FormatWebP.MIMEType())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Load test AVIF file
|
// Load test AVIF file
|
||||||
@@ -459,14 +454,15 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request resize and convert to JPEG
|
// Request resize and convert to JPEG
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 2, Height: 2},
|
Size: imagerequest.Size{Width: 2, Height: 2},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatAVIF, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
}
|
}
|
||||||
@@ -480,7 +476,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != mimeJPEG {
|
if mime != imagerequest.FormatJPEG.MIMEType() {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,7 +487,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
// Create a processor with a very small byte limit
|
// Create a processor with a very small byte limit
|
||||||
const limit = 1024
|
const limit = 1024
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: limit})
|
proc := New(Params{MaxInputBytes: limit, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create a valid JPEG that exceeds the byte limit
|
// Create a valid JPEG that exceeds the byte limit
|
||||||
@@ -500,14 +496,15 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
_, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
}
|
}
|
||||||
@@ -524,17 +521,18 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
input := createTestJPEG(t, 10, 10)
|
input := createTestJPEG(t, 10, 10)
|
||||||
limit := int64(len(input)) * 10 // 10× headroom
|
limit := int64(len(input)) * 10 // 10× headroom
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: limit})
|
proc := New(Params{MaxInputBytes: limit, MaxConcurrentProcessing: 1})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &Request{
|
transform := &imagerequest.Transform{
|
||||||
Size: Size{Width: 10, Height: 10},
|
Size: imagerequest.Size{Width: 10, Height: 10},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -542,35 +540,25 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
defer func() { _ = result.Content.Close() }()
|
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) {
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
encodeAndCheck(t, imagerequest.FormatAVIF, 85, imagerequest.FormatAVIF.MIMEType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndDecode runs input through Process and decodes the output with
|
// processAndDecode runs input, a JPEG, through Process and decodes the output
|
||||||
// vips, so a test can inspect the image a client would receive.
|
// with vips, so a test can inspect the image a client would receive.
|
||||||
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
|
func processAndDecode(
|
||||||
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
|
) *vips.ImageRef {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{
|
||||||
context.Background(), bytes.NewReader(input), req,
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
context.Background(), bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -614,15 +602,17 @@ func TestImageProcessor_StripsEXIF(t *testing.T) {
|
|||||||
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
|
||||||
}
|
}
|
||||||
|
|
||||||
formats := []Format{
|
formats := []imagerequest.Format{
|
||||||
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatWebP,
|
||||||
|
imagerequest.FormatAVIF, imagerequest.FormatGIF, imagerequest.FormatOriginal,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, format := range formats {
|
for _, format := range formats {
|
||||||
t.Run(string(format), func(t *testing.T) {
|
t.Run(string(format), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: format})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: format})
|
||||||
|
|
||||||
if output.HasExif() {
|
if output.HasExif() {
|
||||||
t.Errorf("output has EXIF: %v", output.GetExif())
|
t.Errorf("output has EXIF: %v", output.GetExif())
|
||||||
@@ -644,21 +634,21 @@ func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size Size
|
size imagerequest.Size
|
||||||
wantW int
|
wantW int
|
||||||
wantH int
|
wantH int
|
||||||
}{
|
}{
|
||||||
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
|
{name: "original size", size: imagerequest.Size{}, wantW: 8, wantH: 16},
|
||||||
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
|
{name: "width only", size: imagerequest.Size{Width: 4}, wantW: 4, wantH: 8},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{
|
output := processAndDecode(t, input, &imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
})
|
})
|
||||||
|
|
||||||
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
if output.Width() != tt.wantW || output.Height() != tt.wantH {
|
||||||
@@ -694,7 +684,8 @@ func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatPNG})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
if output.HasICCProfile() {
|
if output.HasICCProfile() {
|
||||||
t.Error("output has an ICC profile")
|
t.Error("output has an ICC profile")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
||||||
@@ -15,10 +16,17 @@ import (
|
|||||||
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
|
transform := &imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
||||||
|
Format: imagerequest.FormatJXL,
|
||||||
|
}
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)),
|
||||||
|
imagerequest.FormatJPEG, transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -60,8 +68,12 @@ func TestImageProcessor_JPEGXLQuality(t *testing.T) {
|
|||||||
outputBytes := make(map[int]int64)
|
outputBytes := make(map[int]int64)
|
||||||
|
|
||||||
for _, quality := range []int{30, 90} {
|
for _, quality := range []int{30, 90} {
|
||||||
result, err := New(Params{}).Process(t.Context(),
|
result, err := New(Params{
|
||||||
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(t.Context(),
|
||||||
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +101,8 @@ func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
|
exif := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL}).GetExif()
|
||||||
|
|
||||||
for _, field := range []string{
|
for _, field := range []string{
|
||||||
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
||||||
@@ -115,7 +128,8 @@ func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
// libvips gives the resolution in pixels per millimetre.
|
// libvips gives the resolution in pixels per millimetre.
|
||||||
xDPI := math.Round(output.ResX() * 25.4)
|
xDPI := math.Round(output.ResX() * 25.4)
|
||||||
@@ -140,7 +154,8 @@ func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
if output.Width() != 8 || output.Height() != 16 {
|
if output.Width() != 8 || output.Height() != 16 {
|
||||||
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
||||||
@@ -176,7 +191,8 @@ func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
pixel, err := output.GetPoint(4, 4)
|
pixel, err := output.GetPoint(4, 4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,7 +219,8 @@ func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
|
|||||||
t.Fatalf("failed to read test JPEG: %v", err)
|
t.Fatalf("failed to read test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := processAndDecode(t, input, &Request{Format: FormatJXL})
|
output := processAndDecode(t, input,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
||||||
|
|
||||||
if output.Bands() != 3 {
|
if output.Bands() != 3 {
|
||||||
t.Fatalf("output has %d bands, want 3", output.Bands())
|
t.Fatalf("output has %d bands, want 3", output.Bands())
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/iotest"
|
"testing/iotest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errTestReadFailed is the error the unreadable test input returns.
|
// errTestReadFailed is the error the unreadable test input returns.
|
||||||
@@ -76,13 +77,13 @@ func (r *gatedReader) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// smallJPEGRequest asks for a 5x5 JPEG.
|
// smallJPEGTransform asks for a 5x5 JPEG.
|
||||||
func smallJPEGRequest() *Request {
|
func smallJPEGTransform() *imagerequest.Transform {
|
||||||
return &Request{
|
return &imagerequest.Transform{
|
||||||
Size: Size{Width: 5, Height: 5},
|
Size: imagerequest.Size{Width: 5, Height: 5},
|
||||||
Format: FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,8 @@ func processInBackground(
|
|||||||
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
) {
|
) {
|
||||||
go func() {
|
go func() {
|
||||||
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
result, err := proc.Process(context.Background(), reader,
|
||||||
|
imagerequest.FormatJPEG, smallJPEGTransform())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = result.Content.Close()
|
_ = result.Content.Close()
|
||||||
}
|
}
|
||||||
@@ -115,47 +117,15 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
|
func TestNewMakesMaxConcurrentProcessingSlots(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, limit := range []int{0, -1} {
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 3})
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
||||||
if got := cap(proc.processingSemaphore); got != max(runtime.GOMAXPROCS(0)/2, 1) {
|
|
||||||
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, half the CPUs",
|
|
||||||
limit, got, max(runtime.GOMAXPROCS(0)/2, 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 3})
|
|
||||||
if got := cap(proc.processingSemaphore); got != 3 {
|
if got := cap(proc.processingSemaphore); got != 3 {
|
||||||
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewDefaultIsHalfTheCPUsAndAtLeastOne checks that New, given no
|
|
||||||
// MaxConcurrentProcessing, makes one slot for every two CPUs Go uses,
|
|
||||||
// rounded down, and one slot when Go uses one CPU.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
|
|
||||||
func TestNewDefaultIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
|
|
||||||
cpusBefore := runtime.GOMAXPROCS(0)
|
|
||||||
|
|
||||||
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
|
|
||||||
|
|
||||||
for _, tc := range []struct{ cpus, want int }{
|
|
||||||
{1, 1}, {2, 1}, {3, 1}, {8, 4},
|
|
||||||
} {
|
|
||||||
runtime.GOMAXPROCS(tc.cpus)
|
|
||||||
|
|
||||||
got := cap(New(Params{}).processingSemaphore)
|
|
||||||
t.Logf("GOMAXPROCS %d: %d slots", tc.cpus, got)
|
|
||||||
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("GOMAXPROCS %d: %d slots, want %d", tc.cpus, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
||||||
// than MaxConcurrentProcessing allows and holds the first ones inside
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
||||||
// Process until the test lets them go. No more than the limit may be
|
// Process until the test lets them go. No more than the limit may be
|
||||||
@@ -169,7 +139,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|||||||
calls = 6
|
calls = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: limit})
|
||||||
input := createTestJPEG(t, 50, 50)
|
input := createTestJPEG(t, 50, 50)
|
||||||
|
|
||||||
counter := &readingCounter{}
|
counter := &readingCounter{}
|
||||||
@@ -216,7 +186,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|||||||
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
input := createTestJPEG(t, 10, 10)
|
input := createTestJPEG(t, 10, 10)
|
||||||
@@ -237,7 +207,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
smallJPEGRequest())
|
imagerequest.FormatJPEG, smallJPEGTransform())
|
||||||
if !errors.Is(err, ErrTooManyImages) {
|
if !errors.Is(err, ErrTooManyImages) {
|
||||||
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
||||||
}
|
}
|
||||||
@@ -263,38 +233,38 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
|
|
||||||
valid := createTestJPEG(t, 10, 10)
|
valid := createTestJPEG(t, 10, 10)
|
||||||
|
|
||||||
unsupported := smallJPEGRequest()
|
unsupported := smallJPEGTransform()
|
||||||
unsupported.Format = "bmp"
|
unsupported.Format = "bmp"
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
input io.Reader
|
input io.Reader
|
||||||
req *Request
|
transform *imagerequest.Transform
|
||||||
// want is the error Process must return; nil means any error.
|
// want is the error Process must return; nil means any error.
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "input cannot be read",
|
name: "input cannot be read",
|
||||||
input: iotest.ErrReader(errTestReadFailed),
|
input: iotest.ErrReader(errTestReadFailed),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
want: errTestReadFailed,
|
want: errTestReadFailed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "input over the byte limit",
|
name: "input over the byte limit",
|
||||||
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
want: ErrInputDataTooLarge,
|
want: ErrInputDataTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "input not an image",
|
name: "input not an image",
|
||||||
input: strings.NewReader("not an image"),
|
input: strings.NewReader("not an image"),
|
||||||
req: smallJPEGRequest(),
|
transform: smallJPEGTransform(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "output format not supported",
|
name: "output format not supported",
|
||||||
input: bytes.NewReader(valid),
|
input: bytes.NewReader(valid),
|
||||||
req: unsupported,
|
transform: unsupported,
|
||||||
want: ErrUnsupportedOutputFormat,
|
want: ErrUnsupportedOutputFormat,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +275,8 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
_, err := proc.Process(context.Background(), tc.input,
|
||||||
|
imagerequest.FormatJPEG, tc.transform)
|
||||||
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||||
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -315,7 +286,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
smallJPEGRequest())
|
imagerequest.FormatJPEG, smallJPEGTransform())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() after the error = %v, want nil", err)
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -332,7 +303,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
func TestWaitForProcessing(t *testing.T) {
|
func TestWaitForProcessing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxConcurrentProcessing: 2})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 2})
|
||||||
|
|
||||||
gate := make(chan struct{})
|
gate := make(chan struct{})
|
||||||
entered := make(chan struct{}, 1)
|
entered := make(chan struct{}, 1)
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||||
// input, which is not a source format, is refused with
|
// input is refused with ErrUnsupportedInputFormat before it is decoded, and
|
||||||
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
|
// not saved as JPEG. TIFF is not a source format, and the test passes the
|
||||||
|
// input to Process as a JPEG.
|
||||||
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -26,8 +28,12 @@ func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
|||||||
t.Fatalf("failed to save test TIFF: %v", err)
|
t.Fatalf("failed to save test TIFF: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := New(Params{}).Process(
|
result, err := New(Params{
|
||||||
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal},
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
}).Process(
|
||||||
|
t.Context(), bytes.NewReader(tiff), imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
|
||||||
)
|
)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() returned %s", result.ContentType)
|
t.Logf("Process() returned %s", result.ContentType)
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package imageprocessor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
|
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
|
||||||
@@ -12,13 +15,15 @@ import (
|
|||||||
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
for _, format := range []Format{
|
for _, format := range []imagerequest.Format{
|
||||||
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
} {
|
} {
|
||||||
saved, err := proc.Process(t.Context(),
|
saved, err := proc.Process(t.Context(),
|
||||||
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
|
bytes.NewReader(createTestPNG(t, 20, 20)), imagerequest.FormatPNG,
|
||||||
|
&imagerequest.Transform{Format: format})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("saving the test image as %s: %v", format, err)
|
t.Fatalf("saving the test image as %s: %v", format, err)
|
||||||
}
|
}
|
||||||
@@ -28,8 +33,8 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
|||||||
t.Fatalf("reading the %s image: %v", format, err)
|
t.Fatalf("reading the %s image: %v", format, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
result, err := proc.Process(t.Context(), bytes.NewReader(source), format,
|
||||||
&Request{Format: FormatOriginal})
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("orig of the %s image: %v", format, err)
|
t.Errorf("orig of the %s image: %v", format, err)
|
||||||
|
|
||||||
@@ -38,9 +43,33 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("orig of the %s image: %s", format, result.ContentType)
|
t.Logf("orig of the %s image: %s", format, result.ContentType)
|
||||||
|
|
||||||
if result.ContentType != FormatToMIME(format) {
|
if result.ContentType != format.MIMEType() {
|
||||||
t.Errorf("orig of the %s image is %s, want %s",
|
t.Errorf("orig of the %s image is %s, want %s",
|
||||||
format, result.ContentType, FormatToMIME(format))
|
format, result.ContentType, format.MIMEType())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_NotASourceFormatRefused verifies that a source format
|
||||||
|
// other than the six, such as orig, auto or none, is refused with
|
||||||
|
// ErrUnsupportedInputFormat. The input is text, which libvips reads as no
|
||||||
|
// format.
|
||||||
|
func TestImageProcessor_NotASourceFormatRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
|
for _, sourceFormat := range []imagerequest.Format{
|
||||||
|
imagerequest.FormatOriginal, imagerequest.FormatAuto, "",
|
||||||
|
} {
|
||||||
|
result, err := proc.Process(t.Context(),
|
||||||
|
bytes.NewReader([]byte("this is text, not an image")), sourceFormat,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJPEG})
|
||||||
|
t.Logf("source format %q: error = %v", sourceFormat, err)
|
||||||
|
|
||||||
|
if result != nil || !errors.Is(err, ErrUnsupportedInputFormat) {
|
||||||
|
t.Errorf("Process() with source format %q = %v, error %v; want %v",
|
||||||
|
sourceFormat, result, err, ErrUnsupportedInputFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProxy_Get_MakesAgainVariantOfNoOutputFormat replaces a cached
|
||||||
|
// variant's file with text, and checks that the next request is a miss
|
||||||
|
// answered with the variant made again, and the one after a hit.
|
||||||
|
func TestProxy_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := variantFilePath(svc, imagerequest.CacheKey(req))
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"),
|
||||||
|
imgcache.StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replacing the variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the replacement: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"made again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_HitWaitsForNoDatabaseWrite holds the database's one
|
||||||
|
// connection while a cached variant is requested. The hit must be answered
|
||||||
|
// while the connection is still held, and its access time must reach the
|
||||||
|
// database once it is free.
|
||||||
|
func TestProxy_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
key := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
getImage(t, svc, req)
|
||||||
|
|
||||||
|
// Each connection to an in-memory SQLite database opens a new, empty one,
|
||||||
|
// so the goroutine that writes the access times must share the
|
||||||
|
// connection that holds the schema.
|
||||||
|
svc.db.SetMaxOpenConns(1)
|
||||||
|
svc.cache.StartPendingAccessTimeWrites()
|
||||||
|
|
||||||
|
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||||
|
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setVariantLastAccessed(t, svc.db, key, anHourAgo)
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.db)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
results := startGet(t.Context(), svc, req)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingAccessTimesWait):
|
||||||
|
t.Fatal("the cache hit waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingAccessTimesWait)
|
||||||
|
|
||||||
|
for countRows(t, svc.db, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the hit's access time was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validators the test upstream sends with its image.
|
||||||
|
const (
|
||||||
|
testETag = `"v1"`
|
||||||
|
testLastModified = "Mon, 02 Nov 2026 10:00:00 GMT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errConnectionRefused stands for an upstream that cannot be reached.
|
||||||
|
var errConnectionRefused = errors.New("connection refused")
|
||||||
|
|
||||||
|
// upstreamFailure is one way a fetch from upstream fails.
|
||||||
|
type upstreamFailure struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamFailures returns the ways a check of a cached image with upstream
|
||||||
|
// can fail: an error status, a timeout and no connection.
|
||||||
|
func upstreamFailures() []upstreamFailure {
|
||||||
|
return []upstreamFailure{
|
||||||
|
{"error status", fmt.Errorf("%w: status 503", httpfetcher.ErrUpstreamError)},
|
||||||
|
{"timeout", httpfetcher.ErrUpstreamTimeout},
|
||||||
|
{"no connection", errConnectionRefused},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditionalUpstream serves one JPEG image with an ETag or a Last-Modified
|
||||||
|
// header, or both. It answers 304 Not Modified to a fetch whose If-None-Match
|
||||||
|
// is its ETag, or, when it has no ETag, whose If-Modified-Since is its
|
||||||
|
// Last-Modified. While err is set, every fetch fails with it. It records the
|
||||||
|
// conditional headers of every fetch.
|
||||||
|
type conditionalUpstream struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
image []byte
|
||||||
|
etag string
|
||||||
|
lastModified string
|
||||||
|
err error
|
||||||
|
|
||||||
|
ifNoneMatch []string
|
||||||
|
ifModifiedSince []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *conditionalUpstream) Fetch(
|
||||||
|
_ context.Context, _ imagerequest.Source,
|
||||||
|
ifNoneMatch, ifModifiedSince string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
|
||||||
|
u.ifNoneMatch = append(u.ifNoneMatch, ifNoneMatch)
|
||||||
|
u.ifModifiedSince = append(u.ifModifiedSince, ifModifiedSince)
|
||||||
|
|
||||||
|
if u.err != nil {
|
||||||
|
return nil, u.err
|
||||||
|
}
|
||||||
|
|
||||||
|
unchanged := ifNoneMatch == u.etag
|
||||||
|
if u.etag == "" {
|
||||||
|
unchanged = ifModifiedSince == u.lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
if unchanged {
|
||||||
|
return &httpfetcher.FetchResult{
|
||||||
|
Content: http.NoBody,
|
||||||
|
StatusCode: http.StatusNotModified,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := http.Header{}
|
||||||
|
if u.etag != "" {
|
||||||
|
headers.Set("ETag", u.etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.lastModified != "" {
|
||||||
|
headers.Set("Last-Modified", u.lastModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &httpfetcher.FetchResult{
|
||||||
|
Content: io.NopCloser(bytes.NewReader(u.image)),
|
||||||
|
ContentLength: int64(len(u.image)),
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
Headers: headers,
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// change replaces the upstream image and its ETag.
|
||||||
|
func (u *conditionalUpstream) change(image []byte, etag string) {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
|
||||||
|
u.image = image
|
||||||
|
u.etag = etag
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail makes every later fetch fail with err.
|
||||||
|
func (u *conditionalUpstream) fail(err error) {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
|
||||||
|
u.err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetches returns how many fetches upstream was asked for.
|
||||||
|
func (u *conditionalUpstream) fetches() int {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
|
||||||
|
return len(u.ifNoneMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastConditionalHeaders returns the If-None-Match and If-Modified-Since of
|
||||||
|
// the last fetch.
|
||||||
|
func (u *conditionalUpstream) lastConditionalHeaders() (string, string) {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
|
||||||
|
last := len(u.ifNoneMatch) - 1
|
||||||
|
|
||||||
|
return u.ifNoneMatch[last], u.ifModifiedSince[last]
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupConditionalUpstreamProxy returns a proxy whose upstream is a
|
||||||
|
// conditionalUpstream serving a red JPEG with an ETag.
|
||||||
|
func setupConditionalUpstreamProxy(
|
||||||
|
t *testing.T,
|
||||||
|
) (*testProxy, *TestFixtures, *conditionalUpstream) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
upstream := &conditionalUpstream{
|
||||||
|
image: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}),
|
||||||
|
etag: testETag,
|
||||||
|
}
|
||||||
|
svc.fetcher = upstream
|
||||||
|
|
||||||
|
return svc, fixtures, upstream
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_FreshVariantAsksUpstreamNothing checks that a variant whose
|
||||||
|
// source was checked with upstream less than the cache TTL ago is served from
|
||||||
|
// the cache without asking upstream.
|
||||||
|
func TestProxy_Get_FreshVariantAsksUpstreamNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
first, _ := getImage(t, svc, variant)
|
||||||
|
second, status := getImage(t, svc, variant)
|
||||||
|
|
||||||
|
t.Logf("cache status = %s, upstream fetches = %d", status, upstream.fetches())
|
||||||
|
|
||||||
|
if status != CacheHit {
|
||||||
|
t.Errorf("CacheStatus = %q, want %q", status, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Error("the cached variant differs from the one first served")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := upstream.fetches(); n != 1 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_UnchangedSourceAfterTTLIsNotDownloadedAgain caches a variant,
|
||||||
|
// sets its source's check time and the variant's file time two days back,
|
||||||
|
// and requests it again, with the source still cached and with the source's
|
||||||
|
// row and file removed, as eviction does. Upstream must be asked with the
|
||||||
|
// validator it sent, answer 304, and send no bytes; the variant must be served
|
||||||
|
// again without being made again, and then be served from the cache until the
|
||||||
|
// TTL passes.
|
||||||
|
func TestProxy_Get_UnchangedSourceAfterTTLIsNotDownloadedAgain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sourceEvicted bool
|
||||||
|
etag string
|
||||||
|
lastModified string
|
||||||
|
wantIfNoneMatch string
|
||||||
|
wantIfModifiedSince string
|
||||||
|
}{
|
||||||
|
{"ETag", false, testETag, "", testETag, ""},
|
||||||
|
{"Last-Modified only", false, "", testLastModified, "", testLastModified},
|
||||||
|
{"both", false, testETag, testLastModified, testETag, testLastModified},
|
||||||
|
{"ETag, source evicted", true, testETag, "", testETag, ""},
|
||||||
|
{"Last-Modified only, source evicted", true, "", testLastModified, "",
|
||||||
|
testLastModified},
|
||||||
|
{"both, source evicted", true, testETag, testLastModified, testETag,
|
||||||
|
testLastModified},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
upstream.etag = tt.etag
|
||||||
|
upstream.lastModified = tt.lastModified
|
||||||
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
first, _ := getImage(t, svc, variant)
|
||||||
|
setBackSourceAndVariant(t, svc, variant, time.Now().AddDate(0, 0, -2))
|
||||||
|
|
||||||
|
if tt.sourceEvicted {
|
||||||
|
evictSource(t, svc, variant.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, status := getImage(t, svc, variant)
|
||||||
|
|
||||||
|
ifNoneMatch, ifModifiedSince := upstream.lastConditionalHeaders()
|
||||||
|
counters := readCounters(t, svc)
|
||||||
|
t.Logf("cache status = %s, If-None-Match = %q, If-Modified-Since = %q, "+
|
||||||
|
"counters = %+v", status, ifNoneMatch, ifModifiedSince, counters)
|
||||||
|
|
||||||
|
if ifNoneMatch != tt.wantIfNoneMatch {
|
||||||
|
t.Errorf("If-None-Match = %q, want %q", ifNoneMatch, tt.wantIfNoneMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ifModifiedSince != tt.wantIfModifiedSince {
|
||||||
|
t.Errorf("If-Modified-Since = %q, want %q",
|
||||||
|
ifModifiedSince, tt.wantIfModifiedSince)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Error("the variant served after the check differs from the cached one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if counters.upstreamFetchCount != 1 || counters.transformCount != 1 {
|
||||||
|
t.Errorf("upstream downloads = %d, transforms = %d; want 1 and 1",
|
||||||
|
counters.upstreamFetchCount, counters.transformCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, status = getImage(t, svc, variant)
|
||||||
|
if status != CacheHit {
|
||||||
|
t.Errorf("CacheStatus after the check = %q, want %q", status, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := upstream.fetches(); n != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_ChangedSourceAfterTTLReplacesSourceAndVariants caches two
|
||||||
|
// variants of one source, changes the image upstream, and sets the source's
|
||||||
|
// check time and both variants' file times two days back. Each variant must
|
||||||
|
// then be made again from the new image, which is downloaded once.
|
||||||
|
func TestProxy_Get_ChangedSourceAfterTTLReplacesSourceAndVariants(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
variants := []*imagerequest.Request{
|
||||||
|
photoVariant(fixtures, 85, imagerequest.FitCover),
|
||||||
|
photoVariant(fixtures, 40, imagerequest.FitCover),
|
||||||
|
}
|
||||||
|
|
||||||
|
oldImages := make([][]byte, len(variants))
|
||||||
|
for i, variant := range variants {
|
||||||
|
oldImages[i], _ = getImage(t, svc, variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource := generateTestJPEG(t, 120, 90, color.RGBA{0, 0, 255, 255})
|
||||||
|
upstream.change(newSource, `"v2"`)
|
||||||
|
|
||||||
|
twoDaysAgo := time.Now().AddDate(0, 0, -2)
|
||||||
|
for _, variant := range variants {
|
||||||
|
setBackSourceAndVariant(t, svc, variant, twoDaysAgo)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, variant := range variants {
|
||||||
|
image, _ := getImage(t, svc, variant)
|
||||||
|
if bytes.Equal(image, oldImages[i]) {
|
||||||
|
t.Errorf("variant %d is still made from the old image", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counters := readCounters(t, svc)
|
||||||
|
t.Logf("counters = %+v, upstream fetches = %d", counters, upstream.fetches())
|
||||||
|
|
||||||
|
if counters.upstreamFetchCount != 2 {
|
||||||
|
t.Errorf("upstream downloads = %d, want 2", counters.upstreamFetchCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err := os.ReadFile(sourceFilePath(svc, variants[0].Source.Key()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the cached source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(cached, newSource) {
|
||||||
|
t.Error("the cached source is not the new image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_UpstreamFailureAfterTTLServesCachedImage caches a variant,
|
||||||
|
// sets its source's check time and the variant's file time two days back, and
|
||||||
|
// makes upstream fail. The cached variant must still be served, and a variant
|
||||||
|
// not yet cached must be made from the cached source.
|
||||||
|
func TestProxy_Get_UpstreamFailureAfterTTLServesCachedImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, failure := range upstreamFailures() {
|
||||||
|
t.Run(failure.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
first, _ := getImage(t, svc, variant)
|
||||||
|
setBackSourceAndVariant(t, svc, variant, time.Now().AddDate(0, 0, -2))
|
||||||
|
upstream.fail(failure.err)
|
||||||
|
|
||||||
|
second, _ := getImage(t, svc, variant)
|
||||||
|
t.Logf("upstream fetches = %d", upstream.fetches())
|
||||||
|
|
||||||
|
if upstream.fetches() != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", upstream.fetches())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Error("the variant served while upstream fails differs from the cached one")
|
||||||
|
}
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_FailingUpstreamIsAskedOnceWhileImageIsServedOften caches a
|
||||||
|
// variant, sets its source's check time and the variant's file time two days
|
||||||
|
// back, and makes every fetch time out. Serving the variant ten times, and a
|
||||||
|
// variant not yet cached, must ask upstream once: the failed check puts the
|
||||||
|
// source URL in the negative cache.
|
||||||
|
func TestProxy_Get_FailingUpstreamIsAskedOnceWhileImageIsServedOften(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
getImage(t, svc, variant)
|
||||||
|
setBackSourceAndVariant(t, svc, variant, time.Now().AddDate(0, 0, -2))
|
||||||
|
upstream.fail(httpfetcher.ErrUpstreamTimeout)
|
||||||
|
|
||||||
|
for range 10 {
|
||||||
|
getImage(t, svc, variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
|
||||||
|
t.Logf("upstream fetches = %d", upstream.fetches())
|
||||||
|
|
||||||
|
if n := upstream.fetches(); n != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2: the first fetch and one check", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_UpstreamFailureAfterTTLServesVariantOfEvictedSource caches a
|
||||||
|
// variant, sets its file time two days back, removes its source's row and
|
||||||
|
// file, as eviction does, and makes upstream fail. The cached variant must
|
||||||
|
// still be served.
|
||||||
|
func TestProxy_Get_UpstreamFailureAfterTTLServesVariantOfEvictedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, failure := range upstreamFailures() {
|
||||||
|
t.Run(failure.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
first, _ := getImage(t, svc, variant)
|
||||||
|
setBackSourceAndVariant(t, svc, variant, time.Now().AddDate(0, 0, -2))
|
||||||
|
evictSource(t, svc, variant.Source)
|
||||||
|
upstream.fail(failure.err)
|
||||||
|
|
||||||
|
second, _ := getImage(t, svc, variant)
|
||||||
|
t.Logf("upstream fetches = %d", upstream.fetches())
|
||||||
|
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Error("the variant served while upstream fails differs from the cached one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := upstream.fetches(); n != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_FailingUpstreamIsAskedOnceForVariantOfEvictedSource caches a
|
||||||
|
// variant, sets its file time two days back, removes its source's row and
|
||||||
|
// file, as eviction does, and makes every fetch time out. Serving the variant
|
||||||
|
// ten times must ask upstream once: the failed check puts the source URL in
|
||||||
|
// the negative cache.
|
||||||
|
func TestProxy_Get_FailingUpstreamIsAskedOnceForVariantOfEvictedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, upstream := setupConditionalUpstreamProxy(t)
|
||||||
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
first, _ := getImage(t, svc, variant)
|
||||||
|
setBackSourceAndVariant(t, svc, variant, time.Now().AddDate(0, 0, -2))
|
||||||
|
evictSource(t, svc, variant.Source)
|
||||||
|
upstream.fail(httpfetcher.ErrUpstreamTimeout)
|
||||||
|
|
||||||
|
for range 10 {
|
||||||
|
image, _ := getImage(t, svc, variant)
|
||||||
|
if !bytes.Equal(image, first) {
|
||||||
|
t.Fatal("the variant served while upstream fails differs from the cached one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("upstream fetches = %d", upstream.fetches())
|
||||||
|
|
||||||
|
if n := upstream.fetches(); n != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2: the first fetch and one check", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictSource removes the row and then the file of src's cached source, as
|
||||||
|
// the cache's eviction does, and leaves its variants cached.
|
||||||
|
func evictSource(t *testing.T, svc *testProxy, src imagerequest.Source) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := svc.db.ExecContext(t.Context(),
|
||||||
|
`DELETE FROM source_content WHERE source_key = ?`, string(src.Key()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deleting the source's row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(sourceFilePath(svc, src.Key()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing the source's file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setBackSourceAndVariant sets back to when the times the cache keeps for
|
||||||
|
// req's source and for the variant req asks for: the source's fetch and check
|
||||||
|
// times, both last access times, the variant's check time, and the variant
|
||||||
|
// file's modification time.
|
||||||
|
func setBackSourceAndVariant(
|
||||||
|
t *testing.T, svc *testProxy, req *imagerequest.Request, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key := req.Source.Key()
|
||||||
|
setSourceLastAccessed(t, svc.db, key, when)
|
||||||
|
|
||||||
|
_, err := svc.db.ExecContext(t.Context(),
|
||||||
|
`UPDATE source_content SET fetched_at = ?, checked_at = ?
|
||||||
|
WHERE source_key = ?`, when.UTC(), when.UTC(), string(key))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the source's fetch and check times: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
variantKey := imagerequest.CacheKey(req)
|
||||||
|
setVariantLastAccessed(t, svc.db, variantKey, when)
|
||||||
|
|
||||||
|
_, err = svc.db.ExecContext(t.Context(),
|
||||||
|
`UPDATE variant_content SET checked_at = ? WHERE cache_key = ?`,
|
||||||
|
when.UTC(), string(variantKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the variant's check time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := variantFilePath(svc, variantKey)
|
||||||
|
|
||||||
|
err = os.Chtimes(path, when, when)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-70
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imageproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ type heldFetcher struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *heldFetcher) Fetch(
|
func (f *heldFetcher) Fetch(
|
||||||
ctx context.Context, url string,
|
ctx context.Context, source imagerequest.Source,
|
||||||
|
ifNoneMatch, ifModifiedSince string,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
f.fetches.Add(1)
|
f.fetches.Add(1)
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ func (f *heldFetcher) Fetch(
|
|||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.upstream.Fetch(ctx, url)
|
return f.upstream.Fetch(ctx, source, ifNoneMatch, ifModifiedSince)
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseFetches lets every held fetch, and every later one, go on.
|
// releaseFetches lets every held fetch, and every later one, go on.
|
||||||
@@ -55,15 +57,15 @@ func (f *heldFetcher) releaseFetches() {
|
|||||||
f.releaseOnce.Do(func() { close(f.release) })
|
f.releaseOnce.Do(func() { close(f.release) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupHeldFetchService returns a test service whose fetches go through a
|
// setupHeldFetchProxy returns a test proxy whose fetches go through a
|
||||||
// heldFetcher. Its database is limited to one connection: each connection to
|
// 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
|
// an in-memory SQLite database opens a new, empty one, so requests running at
|
||||||
// once must share the connection that holds the schema.
|
// once must share the connection that holds the schema.
|
||||||
func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher) {
|
func setupHeldFetchProxy(t *testing.T) (*testProxy, *TestFixtures, *heldFetcher) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := setupTestProxy(t)
|
||||||
svc.cache.db.SetMaxOpenConns(1)
|
svc.db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
fetcher := &heldFetcher{
|
fetcher := &heldFetcher{
|
||||||
upstream: svc.fetcher,
|
upstream: svc.fetcher,
|
||||||
@@ -78,16 +80,22 @@ func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
||||||
// given quality and fit mode. Each call returns a new request, as Get writes
|
// given quality and fit mode. Each call returns a new request, which a test
|
||||||
// to the request it is given.
|
// may change.
|
||||||
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest {
|
func photoVariant(
|
||||||
return &ImageRequest{
|
fixtures *TestFixtures, quality int, fit imagerequest.Fit,
|
||||||
SourceHost: fixtures.GoodHost,
|
) *imagerequest.Request {
|
||||||
SourcePath: testPathPhoto,
|
return &imagerequest.Request{
|
||||||
Size: Size{Width: 50, Height: 25},
|
Source: imagerequest.Source{
|
||||||
Format: FormatJPEG,
|
Host: fixtures.GoodHost,
|
||||||
Quality: quality,
|
Path: testPathPhoto,
|
||||||
FitMode: fit,
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 25},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: fit,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +108,7 @@ type getResult struct {
|
|||||||
// startGet calls Get in a goroutine of its own and delivers what it returned
|
// startGet calls Get in a goroutine of its own and delivers what it returned
|
||||||
// on the channel.
|
// on the channel.
|
||||||
func startGet(
|
func startGet(
|
||||||
ctx context.Context, svc *Service, req *ImageRequest,
|
ctx context.Context, svc *testProxy, req *imagerequest.Request,
|
||||||
) <-chan getResult {
|
) <-chan getResult {
|
||||||
results := make(chan getResult, 1)
|
results := make(chan getResult, 1)
|
||||||
|
|
||||||
@@ -132,21 +140,21 @@ func jpegSize(data []byte) (int, int) {
|
|||||||
return config.Width, config.Height
|
return config.Width, config.Height
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_ConcurrentMissesShareOneFetch starts several requests for
|
// TestProxy_Get_ConcurrentMissesShareOneFetch starts several requests for
|
||||||
// one uncached variant while the first one's fetch is held. Between them they
|
// 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
|
// 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.
|
// with the same 50x25 JPEG, and each must count one miss.
|
||||||
func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
func TestProxy_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
const requests = 8
|
const requests = 8
|
||||||
|
|
||||||
pending := make([]<-chan getResult, 0, requests)
|
pending := make([]<-chan getResult, 0, requests)
|
||||||
for range requests {
|
for range requests {
|
||||||
pending = append(pending,
|
pending = append(pending,
|
||||||
startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)))
|
startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover)))
|
||||||
}
|
}
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
@@ -176,7 +184,7 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
t.Errorf("%d requests made %d upstream fetches, want 1", requests, fetches)
|
t.Errorf("%d requests made %d upstream fetches, want 1", requests, fetches)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestFS builds the same files the test service's fetcher serves.
|
// NewTestFS builds the same files the test proxy's fetcher serves.
|
||||||
testFS, _ := NewTestFS(t)
|
testFS, _ := NewTestFS(t)
|
||||||
|
|
||||||
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||||
@@ -184,24 +192,26 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
want := counterValues{0, requests, 1, int64(len(photo)), 1}
|
||||||
|
|
||||||
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
if got := readCounters(t, svc); got != want {
|
||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_ConcurrentVariantsStayApart requests three variants of the
|
// TestProxy_Get_ConcurrentVariantsStayApart requests three variants of the
|
||||||
// test photo at once that differ only in quality or fit. Each must be made by
|
// 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.
|
// a fetch and a transcode of its own, and each answer must be its own variant.
|
||||||
func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
func TestProxy_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover))
|
lowQuality := startGet(t.Context(), svc,
|
||||||
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitContain))
|
photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
contain := startGet(t.Context(), svc,
|
||||||
|
photoVariant(fixtures, 85, imagerequest.FitContain))
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
select {
|
select {
|
||||||
@@ -244,21 +254,21 @@ func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_WaiterStopsWhenItsContextEnds has a second request for a
|
// TestProxy_Get_WaiterStopsWhenItsContextEnds has a second request for a
|
||||||
// variant join the first one's held fetch, then ends the second request's
|
// 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,
|
// 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.
|
// while the fetch is still held, and the first must still be answered.
|
||||||
func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
func TestProxy_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
||||||
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, FitCover))
|
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
cancelWaiter()
|
cancelWaiter()
|
||||||
@@ -289,22 +299,22 @@ func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_FirstRequestLeavingKeepsTheWork ends the context of the
|
// TestProxy_Get_FirstRequestLeavingKeepsTheWork ends the context of the
|
||||||
// request whose fetch is held, after a second request has joined it. The fetch
|
// 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
|
// 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
|
// waits for its own work, as every request did before misses were shared, and
|
||||||
// is answered too.
|
// is answered too.
|
||||||
func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
func TestProxy_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
||||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
cancelFirst()
|
cancelFirst()
|
||||||
@@ -328,11 +338,11 @@ func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_ConcurrentMissesShareAFailure has several requests for an
|
// TestProxy_Get_ConcurrentMissesShareAFailure has several requests for an
|
||||||
// image that cannot be served arrive while its fetch is held: the one fetch
|
// image that cannot be served arrive while its fetch is held: the one fetch
|
||||||
// answers all of them with its error. The request after them is answered from
|
// answers all of them with its error. The request after them is answered from
|
||||||
// the negative cache, where the failure is kept.
|
// the negative cache, where the failure is kept.
|
||||||
func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
func TestProxy_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -353,11 +363,11 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
request := func() *ImageRequest {
|
request := func() *imagerequest.Request {
|
||||||
req := photoVariant(fixtures, 85, FitCover)
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
req.SourcePath = tc.path
|
req.Source.Path = tc.path
|
||||||
|
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
@@ -389,17 +399,17 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_EndedRequestFetchesNothing checks that a request whose
|
// TestProxy_Get_EndedRequestFetchesNothing checks that a request whose
|
||||||
// context has already ended when it misses the cache starts no fetch.
|
// context has already ended when it misses the cache starts no fetch.
|
||||||
func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
|
func TestProxy_Get_EndedRequestFetchesNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
_, err := svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
||||||
}
|
}
|
||||||
@@ -409,21 +419,21 @@ func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_ReturnsByItsDeadline gives a request a deadline and holds
|
// TestProxy_Get_ReturnsByItsDeadline gives a request a deadline and holds
|
||||||
// its fetch until the fetch's context ends, as the fetcher does while it waits
|
// 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
|
// for a free connection to the host. The request must return by its deadline
|
||||||
// with the deadline's error.
|
// with the deadline's error.
|
||||||
func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
|
func TestProxy_Get_ReturnsByItsDeadline(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, _ := setupHeldFetchService(t)
|
svc, fixtures, _ := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
const timeout = 200 * time.Millisecond
|
const timeout = 200 * time.Millisecond
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case got := <-results:
|
case got := <-results:
|
||||||
@@ -435,18 +445,18 @@ func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_WaiterGetsProcessingTimedOut has a request with no deadline
|
// TestProxy_Get_WaiterGetsProcessingTimedOut has a request with no deadline
|
||||||
// join the held fetch of a first request with a short one. With the only
|
// join the held fetch of a first request with a short one. With the only
|
||||||
// processing slot held, the first request's transcode waits for it until the
|
// processing slot held, the first request's transcode waits for it until the
|
||||||
// first request's deadline ends, and stops then. The waiting request, whose
|
// first request's deadline ends, and stops then. The waiting request, whose
|
||||||
// own context has not ended, must get ErrProcessingTimedOut, which the image
|
// own context has not ended, must get ErrProcessingTimedOut, which the image
|
||||||
// routes answer 503 as when pixa is busy.
|
// routes answer 503 as when pixa is busy.
|
||||||
func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
func TestProxy_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
svc.processor = imageprocessor.New(
|
svc.processor = imageprocessor.New(
|
||||||
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
holdProcessingSlot(t, svc.processor)
|
holdProcessingSlot(t, svc.processor)
|
||||||
@@ -456,11 +466,11 @@ func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
|||||||
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
|
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
|
||||||
defer cancelFirst()
|
defer cancelFirst()
|
||||||
|
|
||||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
<-fetcher.started
|
<-fetcher.started
|
||||||
|
|
||||||
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
time.Sleep(arrivalWait)
|
time.Sleep(arrivalWait)
|
||||||
fetcher.releaseFetches()
|
fetcher.releaseFetches()
|
||||||
@@ -480,18 +490,18 @@ func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
|||||||
type panickingFetcher struct{}
|
type panickingFetcher struct{}
|
||||||
|
|
||||||
func (panickingFetcher) Fetch(
|
func (panickingFetcher) Fetch(
|
||||||
context.Context, string,
|
context.Context, imagerequest.Source, string, string,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
panic("upstream fetcher panicked")
|
panic("upstream fetcher panicked")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_PanicBecomesAnError checks that a panic while a variant is
|
// TestProxy_Get_PanicBecomesAnError checks that a panic while a variant is
|
||||||
// being made reaches its request as an error naming the panic, instead of
|
// being made reaches its request as an error naming the panic, instead of
|
||||||
// being raised again.
|
// being raised again.
|
||||||
func TestService_Get_PanicBecomesAnError(t *testing.T) {
|
func TestProxy_Get_PanicBecomesAnError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := setupTestProxy(t)
|
||||||
svc.fetcher = panickingFetcher{}
|
svc.fetcher = panickingFetcher{}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -503,7 +513,7 @@ func TestService_Get_PanicBecomesAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover))
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
||||||
@@ -511,13 +521,13 @@ func TestService_Get_PanicBecomesAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_PanicIsReportedToSentry checks that a panic while a variant
|
// TestProxy_Get_PanicIsReportedToSentry checks that a panic while a variant
|
||||||
// is being made is reported through the Sentry hub on the request's context,
|
// 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.
|
// where the Sentry middleware puts one when sentry_dsn is set.
|
||||||
func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
|
func TestProxy_Get_PanicIsReportedToSentry(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := setupTestProxy(t)
|
||||||
svc.fetcher = panickingFetcher{}
|
svc.fetcher = panickingFetcher{}
|
||||||
|
|
||||||
transport := &sentry.MockTransport{}
|
transport := &sentry.MockTransport{}
|
||||||
@@ -540,7 +550,7 @@ func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, FitCover))
|
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
events := transport.Events()
|
events := transport.Events()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNew_RegistersItsCountersOnItsRegistry checks that New registers the
|
||||||
|
// proxy's counters on the registry it is given, under the names README.md
|
||||||
|
// gives them. The cache registers its own counters on another registry.
|
||||||
|
func TestNew_RegistersItsCountersOnItsRegistry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(setupTestDB(t),
|
||||||
|
imgcache.Config{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||||
|
prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
|
||||||
|
_, err = New(&Config{Cache: cache}, registry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{
|
||||||
|
"pixa_upstream_fetches_total",
|
||||||
|
"pixa_upstream_fetch_bytes_total",
|
||||||
|
"pixa_transforms_total",
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := testutil.GatherAndCount(registry, names...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testutil.GatherAndCount() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != len(names) {
|
||||||
|
t.Errorf("%d of the counters %v are on the registry, want %d",
|
||||||
|
count, names, len(names))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNew_FailsWhenItsCountersCannotBeRegistered makes a second proxy on
|
||||||
|
// the registry of a first. Its counters have the same names, so the
|
||||||
|
// registry refuses them, and New must return that error.
|
||||||
|
func TestNew_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(setupTestDB(t),
|
||||||
|
imgcache.Config{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||||
|
prometheus.NewRegistry())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
|
||||||
|
_, err = New(&Config{Cache: cache}, registry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() of the first proxy error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = New(&Config{Cache: cache}, registry)
|
||||||
|
t.Logf("New() of the second proxy error = %v", err)
|
||||||
|
|
||||||
|
var alreadyRegistered prometheus.AlreadyRegisteredError
|
||||||
|
if !errors.As(err, &alreadyRegistered) {
|
||||||
|
t.Errorf("New() of the second proxy error = %v, want %T",
|
||||||
|
err, alreadyRegistered)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imageproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
// Package imageproxy fetches, transforms, caches and serves images. Its
|
||||||
|
// Proxy serves an image from the cache when it can, and otherwise makes it
|
||||||
|
// from the cached or fetched source image.
|
||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy orchestrates the cache, fetcher, and processor.
|
||||||
|
type Proxy struct {
|
||||||
|
cache *imgcache.Cache
|
||||||
|
fetcher httpfetcher.Fetcher
|
||||||
|
processor *imageprocessor.ImageProcessor
|
||||||
|
log *slog.Logger
|
||||||
|
maxResponseSize int64
|
||||||
|
// variantsInProgress lets the requests that miss the same variant at the
|
||||||
|
// same time share one fetch and one transcode.
|
||||||
|
variantsInProgress singleflight.Group
|
||||||
|
|
||||||
|
// The counts of upstream fetches and transforms, made and registered by
|
||||||
|
// New. They are kept in memory, so adding to one waits for nothing.
|
||||||
|
upstreamFetches prometheus.Counter
|
||||||
|
upstreamFetchBytes prometheus.Counter
|
||||||
|
transforms prometheus.Counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the proxy's configuration.
|
||||||
|
type Config struct {
|
||||||
|
// Cache is the cache instance
|
||||||
|
Cache *imgcache.Cache
|
||||||
|
// Fetcher fetches source images from upstream hosts
|
||||||
|
Fetcher httpfetcher.Fetcher
|
||||||
|
// Processor converts and resizes the images
|
||||||
|
Processor *imageprocessor.ImageProcessor
|
||||||
|
// MaxResponseSize is the largest source accepted, in bytes; a cached
|
||||||
|
// source larger than this is fetched again
|
||||||
|
MaxResponseSize int64
|
||||||
|
// Logger for logging
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// errCacheRequired is returned when the proxy is built without a cache.
|
||||||
|
var errCacheRequired = errors.New("cache is required")
|
||||||
|
|
||||||
|
// New creates a new image proxy and registers its counters on
|
||||||
|
// metricsRegistry. pixad passes Prometheus' default registry, the one
|
||||||
|
// /metrics serves. It fails when metricsRegistry already holds counters of
|
||||||
|
// the same names.
|
||||||
|
func New(
|
||||||
|
cfg *Config, metricsRegistry prometheus.Registerer,
|
||||||
|
) (*Proxy, error) {
|
||||||
|
if cfg.Cache == nil {
|
||||||
|
return nil, errCacheRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
log := cfg.Logger
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// JPEG XL is the default output format, so pixad does not start
|
||||||
|
// without it.
|
||||||
|
err := imageprocessor.CheckJPEGXLSupport()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Proxy{
|
||||||
|
cache: cfg.Cache,
|
||||||
|
fetcher: cfg.Fetcher,
|
||||||
|
processor: cfg.Processor,
|
||||||
|
log: log,
|
||||||
|
maxResponseSize: cfg.MaxResponseSize,
|
||||||
|
upstreamFetches: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pixa_upstream_fetches_total",
|
||||||
|
Help: "Fetches of a source image from upstream that read any bytes.",
|
||||||
|
}),
|
||||||
|
upstreamFetchBytes: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pixa_upstream_fetch_bytes_total",
|
||||||
|
Help: "Bytes read from upstream by fetches of source images.",
|
||||||
|
}),
|
||||||
|
transforms: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pixa_transforms_total",
|
||||||
|
Help: "Images the image processor transformed.",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, counter := range []prometheus.Counter{
|
||||||
|
p.upstreamFetches, p.upstreamFetchBytes, p.transforms,
|
||||||
|
} {
|
||||||
|
err = metricsRegistry.Register(counter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to register the proxy's counters: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNegativeCached is returned when a URL is in the negative cache (recently failed).
|
||||||
|
var ErrNegativeCached = errors.New("request is in negative cache (recently failed)")
|
||||||
|
|
||||||
|
// ErrProcessingTimedOut is returned when processing an image stopped because
|
||||||
|
// the downstream_timeout of the request doing it ended, to that request and to
|
||||||
|
// every request that waited for its image.
|
||||||
|
var ErrProcessingTimedOut = errors.New(
|
||||||
|
"image processing stopped at the deadline of the request doing it")
|
||||||
|
|
||||||
|
// ImageResponse represents a processed image ready to serve
|
||||||
|
type ImageResponse struct {
|
||||||
|
// Content is the image data reader
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes (-1 if unknown)
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type of the response
|
||||||
|
ContentType string
|
||||||
|
// ETag is the entity tag for caching
|
||||||
|
ETag string
|
||||||
|
// CacheStatus indicates HIT or MISS
|
||||||
|
CacheStatus CacheStatus
|
||||||
|
// FetchedBytes is the number of bytes fetched from upstream (0 if cache hit)
|
||||||
|
FetchedBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheStatus indicates whether the response was served from cache.
|
||||||
|
type CacheStatus string
|
||||||
|
|
||||||
|
// Cache status values for response headers.
|
||||||
|
const (
|
||||||
|
CacheHit CacheStatus = "HIT"
|
||||||
|
CacheMiss CacheStatus = "MISS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get retrieves a processed image, fetching and processing if necessary.
|
||||||
|
// While its source URL is in the negative cache, a cached variant is still
|
||||||
|
// served and a variant not in the cache is still made from a cached source;
|
||||||
|
// only a request that needs the source fetched is refused.
|
||||||
|
//
|
||||||
|
// A cache hit opens the variant file and makes no database call: the cache
|
||||||
|
// hands its access time to the goroutine that writes access times, without
|
||||||
|
// waiting for it, and its hit is counted in memory. A variant whose source
|
||||||
|
// was last checked with upstream CacheTTL or more ago is a miss, which checks
|
||||||
|
// the source first (see processFromSourceOrFetch). A variant file that
|
||||||
|
// cannot be served, such as one whose first bytes are not those of an output
|
||||||
|
// format, is a miss, and the variant is made again.
|
||||||
|
func (p *Proxy) Get(
|
||||||
|
ctx context.Context, req *imagerequest.Request,
|
||||||
|
) (*ImageResponse, error) {
|
||||||
|
cacheKey := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
cached, err := p.cache.Variant(cacheKey)
|
||||||
|
if err == nil {
|
||||||
|
p.cache.IncrementStats(true)
|
||||||
|
|
||||||
|
return &ImageResponse{
|
||||||
|
Content: cached.Content,
|
||||||
|
ContentLength: cached.Size,
|
||||||
|
ContentType: cached.ContentType,
|
||||||
|
CacheStatus: CacheHit,
|
||||||
|
ETag: formatETag(cacheKey, cached.CheckedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, imgcache.ErrNotFound) && !errors.Is(err, imgcache.ErrStale) {
|
||||||
|
p.log.Warn("cached variant not served, making it again",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - get the variant, processed once for all the requests that
|
||||||
|
// miss it at the same time, then count this request's miss, also when it
|
||||||
|
// failed or the request context has ended meanwhile
|
||||||
|
response, err := p.processOrWait(ctx, req)
|
||||||
|
|
||||||
|
p.cache.IncrementStats(false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.CacheStatus = CacheMiss
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
|
// ends, and returns how many images were still being processed then.
|
||||||
|
func (p *Proxy) WaitForProcessing(ctx context.Context) int {
|
||||||
|
return p.processor.WaitForProcessing(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errPanicked is returned when processing a variant panicked.
|
||||||
|
var errPanicked = errors.New("panic while processing image")
|
||||||
|
|
||||||
|
// processOrWait returns the variant req asks for. The first of the requests
|
||||||
|
// that miss a variant at the same time processes it, and singleflight hands
|
||||||
|
// its result, or its error, to the others: they fetch nothing, read no source
|
||||||
|
// and take no upstream connection or processing slot. The processing ignores
|
||||||
|
// the first request's cancellation, so the others are still served if that
|
||||||
|
// client goes away, but keeps its deadline.
|
||||||
|
func (p *Proxy) processOrWait(
|
||||||
|
ctx context.Context, req *imagerequest.Request,
|
||||||
|
) (*ImageResponse, error) {
|
||||||
|
// A request that has already ended starts no processing
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
// Closed when this request's own function runs, which singleflight does
|
||||||
|
// only when no other request is processing the variant
|
||||||
|
processing := make(chan struct{})
|
||||||
|
|
||||||
|
results := p.variantsInProgress.DoChan(string(cacheKey),
|
||||||
|
func() (_ any, err error) {
|
||||||
|
close(processing)
|
||||||
|
|
||||||
|
// singleflight would raise a panic again in a goroutine of its
|
||||||
|
// own, where no handler recovers it, and stop pixad. It is
|
||||||
|
// reported through the Sentry hub that the Sentry middleware puts
|
||||||
|
// on the request's context when sentry_dsn is set.
|
||||||
|
defer func() {
|
||||||
|
recovered := recover()
|
||||||
|
if recovered != nil {
|
||||||
|
p.log.Error("panic while processing image",
|
||||||
|
"host", req.Source.Host, "path", req.Source.Path,
|
||||||
|
"panic", recovered, "stack", string(debug.Stack()))
|
||||||
|
|
||||||
|
if hub := sentry.GetHubFromContext(ctx); hub != nil {
|
||||||
|
hub.RecoverWithContext(ctx, recovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fmt.Errorf("%w: %v", errPanicked, recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
processingCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return p.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
var result singleflight.Result
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result = <-results:
|
||||||
|
case <-ctx.Done():
|
||||||
|
select {
|
||||||
|
case <-processing:
|
||||||
|
// This request is processing the variant: it waits for the
|
||||||
|
// result, as every request did before misses were shared
|
||||||
|
result = <-results
|
||||||
|
default:
|
||||||
|
// Another request is processing the variant, or this request's
|
||||||
|
// function has not started yet; the processing goes on without it
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Err != nil {
|
||||||
|
return nil, result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
variant, _ := result.Val.(*processedVariant)
|
||||||
|
|
||||||
|
return &ImageResponse{
|
||||||
|
Content: io.NopCloser(bytes.NewReader(variant.data)),
|
||||||
|
ContentLength: int64(len(variant.data)),
|
||||||
|
ContentType: variant.contentType,
|
||||||
|
FetchedBytes: variant.fetchedBytes,
|
||||||
|
ETag: formatETag(cacheKey, variant.checkedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutCancelKeepingDeadline returns ctx without its cancellation but with
|
||||||
|
// its deadline, if it has one, and the func that releases the returned
|
||||||
|
// context.
|
||||||
|
func withoutCancelKeepingDeadline(
|
||||||
|
ctx context.Context,
|
||||||
|
) (context.Context, context.CancelFunc) {
|
||||||
|
deadline, hasDeadline := ctx.Deadline()
|
||||||
|
if !hasDeadline {
|
||||||
|
return context.WithoutCancel(ctx), func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCachedSource opens src's cached source, reading only the first bytes,
|
||||||
|
// which give its format, and returns it with its size and its format. It
|
||||||
|
// returns nil if the source is not in the cache, cannot be read, is in no
|
||||||
|
// source format or exceeds maxResponseSize.
|
||||||
|
func (p *Proxy) loadCachedSource(
|
||||||
|
ctx context.Context, src imagerequest.Source,
|
||||||
|
) (io.ReadCloser, int64, imagerequest.Format) {
|
||||||
|
reader, size, format, err := p.cache.Source(ctx, src)
|
||||||
|
if errors.Is(err, imgcache.ErrNotFound) {
|
||||||
|
return nil, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
p.log.Debug("using cached source", "source_key", src.Key())
|
||||||
|
|
||||||
|
if size > p.maxResponseSize {
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
p.log.Warn("cached source exceeds max response size, discarding",
|
||||||
|
"source_key", src.Key(),
|
||||||
|
"max_bytes", p.maxResponseSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader, size, format
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFromSourceOrFetch makes the variant req asks for, from the cached
|
||||||
|
// source when there is one and from a fetched source otherwise.
|
||||||
|
//
|
||||||
|
// A cached source last checked with upstream CacheTTL or more ago is checked
|
||||||
|
// first: upstream is asked to send it only if it changed since. When it
|
||||||
|
// changed, the new bytes replace the cached source and the variant is made
|
||||||
|
// from them. When it did not change, or the check fails, the cached source
|
||||||
|
// is used: the variant's cached file is served again if it was made from the
|
||||||
|
// cached source's bytes, and otherwise the variant is made from them. When
|
||||||
|
// the source is not cached but the variant is, the variant is checked with
|
||||||
|
// upstream the same way (see fetchAndProcess).
|
||||||
|
func (p *Proxy) processFromSourceOrFetch(
|
||||||
|
ctx context.Context,
|
||||||
|
req *imagerequest.Request,
|
||||||
|
cacheKey imagerequest.VariantKey,
|
||||||
|
) (*processedVariant, error) {
|
||||||
|
cached, err := p.cache.LookupSource(ctx, req.Source)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, imgcache.ErrNotFound) {
|
||||||
|
p.log.Warn("failed to look up cached source, fetching", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.fetchAndProcess(ctx, req, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.cache.Stale(cached.CheckedAt) {
|
||||||
|
fetched, err := p.fetchSource(ctx, req, cached.ETag, cached.LastModified)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errSourceNotModified):
|
||||||
|
cached.CheckedAt = time.Now().UTC()
|
||||||
|
p.renewSource(ctx, req.Source, cached.CheckedAt)
|
||||||
|
case err != nil:
|
||||||
|
p.recordFailedCheck(ctx, req, err)
|
||||||
|
default:
|
||||||
|
return p.processFetched(ctx, req, cacheKey, fetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variant := p.renewVariant(ctx, cacheKey, cached)
|
||||||
|
if variant != nil {
|
||||||
|
return variant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
source, sourceSize, sourceFormat := p.loadCachedSource(ctx, req.Source)
|
||||||
|
|
||||||
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
|
if source == nil {
|
||||||
|
return p.fetchAndProcess(ctx, req, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = source.Close() }()
|
||||||
|
|
||||||
|
// Process using cached source; nothing was fetched from upstream. The
|
||||||
|
// image processor reads the source only once it has a processing slot,
|
||||||
|
// so a request waiting for one holds none of it in memory.
|
||||||
|
return p.processAndStore(ctx, req, cacheKey, cached, source, sourceFormat,
|
||||||
|
sourceSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFailedCheck logs a failed check of req's source with upstream, after
|
||||||
|
// which the image is still served from the cache. It puts the source URL in
|
||||||
|
// the negative cache, whatever the failure, a timeout included, so upstream
|
||||||
|
// is asked at most once per NegativeTTL while it fails, however often the
|
||||||
|
// image is served. A failure that was a negative cache hit records nothing.
|
||||||
|
func (p *Proxy) recordFailedCheck(
|
||||||
|
ctx context.Context, req *imagerequest.Request, err error,
|
||||||
|
) {
|
||||||
|
if errors.Is(err, ErrNegativeCached) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.log.Warn("checking source with upstream failed, serving from the cache",
|
||||||
|
"request_id", middleware.GetReqID(ctx),
|
||||||
|
"host", req.Source.Host, "path", req.Source.Path, "error", err)
|
||||||
|
p.storeNegative(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewSource records that upstream answered at checkedAt that src's cached
|
||||||
|
// source had not changed. A failure to record it is only logged: the source
|
||||||
|
// is then checked again on its next use.
|
||||||
|
func (p *Proxy) renewSource(
|
||||||
|
ctx context.Context, src imagerequest.Source, checkedAt time.Time,
|
||||||
|
) {
|
||||||
|
err := p.cache.RenewSource(ctx, src, checkedAt)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to renew cached source", "source_key", src.Key(),
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewVariant returns the cached variant of cacheKey when it was made from
|
||||||
|
// the bytes of the cached source that source describes, and renews it to
|
||||||
|
// source.CheckedAt (see imgcache.Cache.RenewVariant). It returns nil when
|
||||||
|
// there is no such variant or it cannot be read.
|
||||||
|
func (p *Proxy) renewVariant(
|
||||||
|
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||||
|
source imgcache.SourceInfo,
|
||||||
|
) *processedVariant {
|
||||||
|
cached, err := p.cache.RenewVariant(ctx, cacheKey, source)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, imgcache.ErrNotFound) {
|
||||||
|
p.log.Warn("cached variant not renewed, making it again",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.readCachedVariant(cacheKey, cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedVariantIgnoringTTL returns the cached variant of cacheKey whatever
|
||||||
|
// its age, with what its row keeps about the source bytes it was made from
|
||||||
|
// (see imgcache.Cache.LookupVariant). It returns nil when the variant is not
|
||||||
|
// cached or cannot be read. The SourceInfo is empty when the variant is not
|
||||||
|
// returned, or its row is missing or cannot be read.
|
||||||
|
func (p *Proxy) cachedVariantIgnoringTTL(
|
||||||
|
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||||
|
) (*processedVariant, imgcache.SourceInfo) {
|
||||||
|
cached, err := p.cache.VariantIgnoringTTL(cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, imgcache.ErrNotFound) {
|
||||||
|
p.log.Warn("cached variant not served", "cache_key", cacheKey,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, imgcache.SourceInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
variant := p.readCachedVariant(cacheKey, cached)
|
||||||
|
if variant == nil {
|
||||||
|
return nil, imgcache.SourceInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
madeFrom, err := p.cache.LookupVariant(ctx, cacheKey)
|
||||||
|
if err != nil && !errors.Is(err, imgcache.ErrNotFound) {
|
||||||
|
p.log.Warn("failed to look up cached variant", "cache_key", cacheKey,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variant, madeFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCachedVariant reads the cached variant of cacheKey from its opened file,
|
||||||
|
// and closes the file. It returns nil when the variant cannot be read.
|
||||||
|
func (p *Proxy) readCachedVariant(
|
||||||
|
cacheKey imagerequest.VariantKey, file imgcache.VariantFile,
|
||||||
|
) *processedVariant {
|
||||||
|
defer func() { _ = file.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file.Content)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to read cached variant", "cache_key", cacheKey,
|
||||||
|
"error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &processedVariant{
|
||||||
|
data: data,
|
||||||
|
contentType: file.ContentType,
|
||||||
|
checkedAt: file.CheckedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAndProcess fetches the source from upstream, stores it, and makes
|
||||||
|
// and stores the variant from it.
|
||||||
|
//
|
||||||
|
// The variant can still be cached after eviction removed its source. The
|
||||||
|
// fetch then sends the ETag and Last-Modified of the source bytes the variant
|
||||||
|
// was made from, so upstream sends the source only if it changed since. When
|
||||||
|
// it did not change, the cached variant is renewed and served, with no
|
||||||
|
// download and no conversion. When the fetch fails, the cached variant is
|
||||||
|
// served whatever its age, and the failure is recorded as a failed check (see
|
||||||
|
// recordFailedCheck).
|
||||||
|
func (p *Proxy) fetchAndProcess(
|
||||||
|
ctx context.Context,
|
||||||
|
req *imagerequest.Request,
|
||||||
|
cacheKey imagerequest.VariantKey,
|
||||||
|
) (*processedVariant, error) {
|
||||||
|
variant, madeFrom := p.cachedVariantIgnoringTTL(ctx, cacheKey)
|
||||||
|
|
||||||
|
fetched, err := p.fetchSource(ctx, req, madeFrom.ETag, madeFrom.LastModified)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return p.processFetched(ctx, req, cacheKey, fetched)
|
||||||
|
case variant == nil:
|
||||||
|
return nil, err
|
||||||
|
case errors.Is(err, errSourceNotModified):
|
||||||
|
variant.checkedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
err = p.cache.SetVariantCheckedAt(ctx, cacheKey, variant.checkedAt)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to renew cached variant", "cache_key", cacheKey,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
p.recordFailedCheck(ctx, req, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFetched makes and stores the variant from the source fetchSource
|
||||||
|
// fetched, then closes the fetch's response.
|
||||||
|
func (p *Proxy) processFetched(
|
||||||
|
ctx context.Context,
|
||||||
|
req *imagerequest.Request,
|
||||||
|
cacheKey imagerequest.VariantKey,
|
||||||
|
fetched *fetchedSource,
|
||||||
|
) (*processedVariant, error) {
|
||||||
|
// Closing the response frees the upstream connection. It is closed only
|
||||||
|
// after processing, so the fetcher's connection limit also bounds the
|
||||||
|
// fetched sources held in memory while their requests wait for a
|
||||||
|
// processing slot.
|
||||||
|
defer func() { _ = fetched.response.Close() }()
|
||||||
|
|
||||||
|
return p.processAndStore(ctx, req, cacheKey, fetched.info,
|
||||||
|
bytes.NewReader(fetched.data), fetched.format, int64(len(fetched.data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// errSourceNotModified is returned by fetchSource when upstream answers 304
|
||||||
|
// Not Modified: the cached source has not changed.
|
||||||
|
var errSourceNotModified = errors.New("upstream source not modified")
|
||||||
|
|
||||||
|
// fetchedSource is a source fetchSource fetched and stored in the cache.
|
||||||
|
type fetchedSource struct {
|
||||||
|
data []byte
|
||||||
|
format imagerequest.Format
|
||||||
|
// info is what the cache keeps about the source
|
||||||
|
info imgcache.SourceInfo
|
||||||
|
// response is the fetch's response, still open; closing it frees the
|
||||||
|
// upstream connection
|
||||||
|
response io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchSource fetches req's source from upstream and stores it in the cache.
|
||||||
|
// A non-empty ifNoneMatch or ifModifiedSince asks upstream to send the source
|
||||||
|
// only if it changed since; errSourceNotModified says it did not. It counts
|
||||||
|
// the fetch with the bytes read from upstream, including when reading the
|
||||||
|
// response or a later step fails. It fetches nothing while the source URL is
|
||||||
|
// in the negative cache. The caller closes the returned response.
|
||||||
|
func (p *Proxy) fetchSource(
|
||||||
|
ctx context.Context,
|
||||||
|
req *imagerequest.Request,
|
||||||
|
ifNoneMatch, ifModifiedSince string,
|
||||||
|
) (*fetchedSource, error) {
|
||||||
|
// Check negative cache - skip fetching for recently-failed URLs
|
||||||
|
negHit, err := p.cache.Failure(ctx, req.Source)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("negative cache check failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if negHit {
|
||||||
|
p.log.Debug("negative cache hit",
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from upstream
|
||||||
|
p.log.Debug("fetching from upstream", "host", req.Source.Host,
|
||||||
|
"path", req.Source.Path, "query", req.Source.Query)
|
||||||
|
|
||||||
|
fetchResult, err := p.fetcher.Fetch(ctx, req.Source,
|
||||||
|
ifNoneMatch, ifModifiedSince)
|
||||||
|
if err != nil {
|
||||||
|
// Store negative cache for certain errors
|
||||||
|
if isNegativeCacheable(err) {
|
||||||
|
p.storeNegative(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("upstream fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetchResult.StatusCode == http.StatusNotModified {
|
||||||
|
_ = fetchResult.Content.Close()
|
||||||
|
|
||||||
|
p.log.Info("upstream source not modified",
|
||||||
|
"request_id", middleware.GetReqID(ctx), "host", req.Source.Host,
|
||||||
|
"path", req.Source.Path, "fetch_ms", fetchResult.FetchDurationMs)
|
||||||
|
|
||||||
|
return nil, errSourceNotModified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and validate the source content
|
||||||
|
sourceData, err := io.ReadAll(fetchResult.Content)
|
||||||
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
|
// Counted also when the request context has ended meanwhile
|
||||||
|
p.incrementUpstreamFetch(fetchBytes)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
_ = fetchResult.Content.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.logUpstreamFetch(ctx, req, fetchResult, fetchBytes)
|
||||||
|
|
||||||
|
// The source's format, detected from its first bytes, which must match
|
||||||
|
// its content type. A body that does not is the upstream's error, so it
|
||||||
|
// gets a negative cache entry, as an upstream error status does.
|
||||||
|
sourceFormat, err := magic.ValidateMagicBytes(sourceData,
|
||||||
|
fetchResult.ContentType)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("source refused", "request_id", middleware.GetReqID(ctx),
|
||||||
|
"host", req.Source.Host, "path", req.Source.Path,
|
||||||
|
"content_type", fetchResult.ContentType, "error", err)
|
||||||
|
p.storeNegative(ctx, req)
|
||||||
|
|
||||||
|
_ = fetchResult.Content.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := fetchedSourceInfo(fetchResult)
|
||||||
|
|
||||||
|
// Store source content
|
||||||
|
_, err = p.cache.PutSource(ctx, req.Source, bytes.NewReader(sourceData), info)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to store source content", "error", err)
|
||||||
|
// Continue even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return &fetchedSource{
|
||||||
|
data: sourceData,
|
||||||
|
format: sourceFormat,
|
||||||
|
info: info,
|
||||||
|
response: fetchResult.Content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchedSourceInfo returns what the cache keeps about a source upstream
|
||||||
|
// just sent: fetched and checked now, with the ETag and Last-Modified
|
||||||
|
// headers of fetchResult.
|
||||||
|
func fetchedSourceInfo(fetchResult *httpfetcher.FetchResult) imgcache.SourceInfo {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
upstreamHeaders := http.Header(fetchResult.Headers)
|
||||||
|
|
||||||
|
return imgcache.SourceInfo{
|
||||||
|
FetchedAt: now,
|
||||||
|
CheckedAt: now,
|
||||||
|
ETag: upstreamHeaders.Get("ETag"),
|
||||||
|
LastModified: upstreamHeaders.Get("Last-Modified"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logUpstreamFetch logs a fetch of req's source that read fetchBytes bytes,
|
||||||
|
// with its download rate and connection details.
|
||||||
|
func (p *Proxy) logUpstreamFetch(
|
||||||
|
ctx context.Context,
|
||||||
|
req *imagerequest.Request,
|
||||||
|
fetchResult *httpfetcher.FetchResult,
|
||||||
|
fetchBytes int64,
|
||||||
|
) {
|
||||||
|
var downloadRate string
|
||||||
|
|
||||||
|
if fetchResult.FetchDurationMs > 0 {
|
||||||
|
seconds := float64(fetchResult.FetchDurationMs) / 1000.0 //nolint:mnd // ms to seconds
|
||||||
|
bitsPerSecond := float64(fetchBytes*8) / seconds //nolint:mnd // bytes to bits
|
||||||
|
downloadRate = humanize.SI(bitsPerSecond, "bps")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.log.Info("upstream fetched",
|
||||||
|
"request_id", middleware.GetReqID(ctx),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"bytes", fetchBytes,
|
||||||
|
"fetch_ms", fetchResult.FetchDurationMs,
|
||||||
|
"rate", downloadRate,
|
||||||
|
"remote_addr", fetchResult.RemoteAddr,
|
||||||
|
"http", fetchResult.HTTPVersion,
|
||||||
|
"tls", fetchResult.TLSVersion,
|
||||||
|
"cipher", fetchResult.TLSCipherSuite,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// incrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||||
|
// A fetch that read no bytes is not counted.
|
||||||
|
func (p *Proxy) incrementUpstreamFetch(fetchBytes int64) {
|
||||||
|
if fetchBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.upstreamFetches.Inc()
|
||||||
|
p.upstreamFetchBytes.Add(float64(fetchBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeNegative stores a negative cache entry for req's source, whose fetch
|
||||||
|
// failed, so the source is not fetched again until the entry expires. A
|
||||||
|
// failure to store the entry is only logged.
|
||||||
|
func (p *Proxy) storeNegative(ctx context.Context, req *imagerequest.Request) {
|
||||||
|
err := p.cache.PutFailure(ctx, req.Source)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to store negative cache entry",
|
||||||
|
"host", req.Source.Host, "path", req.Source.Path, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processedVariant is a variant as processAndStore made it. Each request that
|
||||||
|
// shared its processing serves it through a reader of its own.
|
||||||
|
type processedVariant struct {
|
||||||
|
data []byte
|
||||||
|
contentType string
|
||||||
|
fetchedBytes int64
|
||||||
|
// checkedAt is its source's check time when the variant was made or last
|
||||||
|
// renewed, which its cached file has as its modification time
|
||||||
|
checkedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAndStore processes the image read from source, which is in
|
||||||
|
// sourceFormat, and stores the result. sourceInfo describes the source's
|
||||||
|
// bytes, and is stored with the variant (see imgcache.Cache.PutVariant).
|
||||||
|
func (p *Proxy) processAndStore(
|
||||||
|
ctx context.Context,
|
||||||
|
req *imagerequest.Request,
|
||||||
|
cacheKey imagerequest.VariantKey,
|
||||||
|
sourceInfo imgcache.SourceInfo,
|
||||||
|
source io.Reader,
|
||||||
|
sourceFormat imagerequest.Format,
|
||||||
|
fetchBytes int64,
|
||||||
|
) (*processedVariant, error) {
|
||||||
|
// Process the image
|
||||||
|
processStart := time.Now()
|
||||||
|
|
||||||
|
// ctx has the deadline of the request doing the processing, which can
|
||||||
|
// end while a request waiting for its image has time left
|
||||||
|
processResult, err := p.processor.Process(ctx, source, sourceFormat,
|
||||||
|
&req.Transform)
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
|
// Counted also when the request context has ended meanwhile
|
||||||
|
p.transforms.Inc()
|
||||||
|
|
||||||
|
// Read processed content
|
||||||
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
|
_ = processResult.Content.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read processed content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log conversion details
|
||||||
|
outputSize := int64(len(processedData))
|
||||||
|
|
||||||
|
var sizePercent float64
|
||||||
|
if fetchBytes > 0 {
|
||||||
|
//nolint:mnd // percentage calculation
|
||||||
|
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
p.log.Info("image converted",
|
||||||
|
"request_id", middleware.GetReqID(ctx),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"src_format", sourceFormat,
|
||||||
|
"dst_format", req.Transform.Format,
|
||||||
|
"src_bytes", fetchBytes,
|
||||||
|
"dst_bytes", outputSize,
|
||||||
|
"src_dimensions", fmt.Sprintf("%dx%d",
|
||||||
|
processResult.InputWidth, processResult.InputHeight),
|
||||||
|
"dst_dimensions", fmt.Sprintf("%dx%d",
|
||||||
|
processResult.Width, processResult.Height),
|
||||||
|
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
||||||
|
"convert_ms", processDuration.Milliseconds(),
|
||||||
|
"quality", req.Transform.Quality,
|
||||||
|
"fit", req.Transform.FitMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store variant to cache
|
||||||
|
err = p.cache.PutVariant(ctx, cacheKey, sourceInfo,
|
||||||
|
bytes.NewReader(processedData))
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("failed to store variant", "error", err)
|
||||||
|
// Continue even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return &processedVariant{
|
||||||
|
data: processedData,
|
||||||
|
contentType: processResult.ContentType,
|
||||||
|
fetchedBytes: fetchBytes,
|
||||||
|
checkedAt: sourceInfo.CheckedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNegativeCacheable returns true if the error should be cached.
|
||||||
|
func isNegativeCacheable(err error) bool {
|
||||||
|
return errors.Is(err, httpfetcher.ErrUpstreamError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// etagHashLength is the number of hash characters to use for ETags.
|
||||||
|
const etagHashLength = 16
|
||||||
|
|
||||||
|
// formatETag returns the quoted ETag of the variant of key: the first
|
||||||
|
// etagHashLength characters of key, then checkedAt in Unix nanoseconds.
|
||||||
|
// checkedAt is its source's check time when the variant was made or last
|
||||||
|
// renewed, so a variant made again from new source bytes gets another ETag.
|
||||||
|
func formatETag(key imagerequest.VariantKey, checkedAt time.Time) string {
|
||||||
|
hash := string(key)
|
||||||
|
// Use first 16 characters of hash for a shorter but still unique ETag
|
||||||
|
if len(hash) > etagHashLength {
|
||||||
|
hash = hash[:etagHashLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`"%s-%d"`, hash, checkedAt.UnixNano())
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test data literals used repeatedly in this file (goconst).
|
||||||
|
const (
|
||||||
|
testPathPhoto = "/images/photo.jpg"
|
||||||
|
testPathUpload = "/uploads/image.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProxy_Get_GoodHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
// Verify we got content
|
||||||
|
data, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Error("expected non-empty response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ContentType != testContentTypeJPEG {
|
||||||
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_OtherHost checks that the proxy serves a second host as it
|
||||||
|
// serves GoodHost.
|
||||||
|
func TestProxy_Get_OtherHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.OtherHost,
|
||||||
|
Path: testPathUpload,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fetch successfully
|
||||||
|
resp, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Error("expected non-empty response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_InvalidFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
req.Source.Path = "/images/fake.jpg"
|
||||||
|
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
||||||
|
|
||||||
|
// Should fail because magic bytes don't match
|
||||||
|
_, err := svc.Get(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Get() expected error for invalid file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_NotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
req.Source.Path = "/images/nonexistent.jpg"
|
||||||
|
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
||||||
|
|
||||||
|
_, err := svc.Get(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Get() expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_FormatConversion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sourcePath string
|
||||||
|
outFormat imagerequest.Format
|
||||||
|
wantMIME string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "JPEG to PNG",
|
||||||
|
sourcePath: testPathPhoto,
|
||||||
|
outFormat: imagerequest.FormatPNG,
|
||||||
|
wantMIME: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PNG to JPEG",
|
||||||
|
sourcePath: "/images/logo.png",
|
||||||
|
outFormat: imagerequest.FormatJPEG,
|
||||||
|
wantMIME: testContentTypeJPEG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GIF to PNG",
|
||||||
|
sourcePath: "/images/animation.gif",
|
||||||
|
outFormat: imagerequest.FormatPNG,
|
||||||
|
wantMIME: "image/png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: fixtures.GoodHost, Path: tt.sourcePath},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: tt.outFormat, Quality: 85, FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
if resp.ContentType != tt.wantMIME {
|
||||||
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify magic bytes match the expected format
|
||||||
|
data, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detectedFormat, err := magic.DetectFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to detect format: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detectedFormat.MIMEType() != tt.wantMIME {
|
||||||
|
t.Errorf("detected format = %q, want the format of %s",
|
||||||
|
detectedFormat, tt.wantMIME)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_Caching(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// First request - should be a cache miss
|
||||||
|
resp1, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp1.CacheStatus != CacheMiss {
|
||||||
|
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
data1, err := io.ReadAll(resp1.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read first response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
|
// Second request - should be a cache hit
|
||||||
|
resp2, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp2.CacheStatus != CacheHit {
|
||||||
|
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
data2, err := io.ReadAll(resp2.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read second response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
|
// Content should be identical
|
||||||
|
if len(data1) != len(data2) {
|
||||||
|
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_DifferentSizes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Request same image at different sizes
|
||||||
|
sizes := []imagerequest.Size{
|
||||||
|
{Width: 25, Height: 25},
|
||||||
|
{Width: 50, Height: 50},
|
||||||
|
{Width: 75, Height: 75},
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([][]byte, 0, len(sizes))
|
||||||
|
|
||||||
|
for _, size := range sizes {
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: size,
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error for size %v = %v", size, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
responses = append(responses, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All responses should be different sizes (different cache entries)
|
||||||
|
for i := range len(responses) - 1 {
|
||||||
|
if len(responses[i]) == len(responses[i+1]) {
|
||||||
|
// Not necessarily an error, but worth noting
|
||||||
|
t.Logf("responses %d and %d have same size: %d bytes",
|
||||||
|
i, i+1, len(responses[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_ContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.Get(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Get() expected error for cancelled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_ReturnsETag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
// ETag should be set
|
||||||
|
if resp.ETag == "" {
|
||||||
|
t.Error("ETag should be set in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETag should be properly quoted
|
||||||
|
if len(resp.ETag) < 2 || resp.ETag[0] != '"' || resp.ETag[len(resp.ETag)-1] != '"' {
|
||||||
|
t.Errorf("ETag should be quoted, got %q", resp.ETag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_ETagConsistency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// First request
|
||||||
|
resp1, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag1 := resp1.ETag
|
||||||
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
|
// Second request (from cache)
|
||||||
|
resp2, err := svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag2 := resp2.ETag
|
||||||
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
|
// ETags should be identical for the same content
|
||||||
|
if etag1 != etag2 {
|
||||||
|
t.Errorf("ETags should match: %q != %q", etag1, etag2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Request same image at different sizes - should get different ETags
|
||||||
|
req1 := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 25, Height: 25},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp1, err := svc.Get(ctx, req1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag1 := resp1.ETag
|
||||||
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
|
resp2, err := svc.Get(ctx, req2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag2 := resp2.ETag
|
||||||
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
|
// ETags should be different for different content
|
||||||
|
if etag1 == etag2 {
|
||||||
|
t.Error("ETags should differ for different sizes")
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-24
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imageproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -9,18 +9,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
||||||
// keep the photo's aspect ratio.
|
// keep the photo's aspect ratio.
|
||||||
func widthOnlyRequest(fixtures *TestFixtures, width int) *ImageRequest {
|
func widthOnlyRequest(fixtures *TestFixtures, width int) *imagerequest.Request {
|
||||||
return &ImageRequest{
|
return &imagerequest.Request{
|
||||||
SourceHost: fixtures.GoodHost,
|
Source: imagerequest.Source{
|
||||||
SourcePath: testPathPhoto,
|
Host: fixtures.GoodHost,
|
||||||
Size: Size{Width: width},
|
Path: testPathPhoto,
|
||||||
Format: FormatJPEG,
|
},
|
||||||
Quality: 85,
|
Transform: imagerequest.Transform{
|
||||||
FitMode: FitCover,
|
Size: imagerequest.Size{Width: width},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +41,8 @@ func holdProcessingSlot(
|
|||||||
input, feed := io.Pipe()
|
input, feed := io.Pipe()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = proc.Process(t.Context(), input, &imageprocessor.Request{})
|
_, _ = proc.Process(t.Context(), input, imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := feed.Write([]byte{0})
|
_, err := feed.Write([]byte{0})
|
||||||
@@ -50,18 +56,19 @@ func holdProcessingSlot(
|
|||||||
return release
|
return release
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestService_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
// TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
||||||
// request whose source is cached holds none of it while it waits for a
|
// request whose source is cached holds none of it while it waits for a
|
||||||
// processing slot: it reads the cached file only once it has a slot. With
|
// processing slot: past the first bytes, which give the source's format, it
|
||||||
// the only slot held, a request for a new width of the cached 100x100 photo
|
// reads the cached file only once it has a slot. With the only slot held, a
|
||||||
// waits; the cached file is then rewritten as a 100x50 image before the slot
|
// request for a new width of the cached 100x100 photo waits; the cached file
|
||||||
// is freed, so the request must answer with that image scaled to 40x20.
|
// is then rewritten as a 100x50 image before the slot is freed, so the
|
||||||
func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
// request must answer with that image scaled to 40x20.
|
||||||
|
func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := setupTestProxy(t)
|
||||||
svc.processor = imageprocessor.New(
|
svc.processor = imageprocessor.New(
|
||||||
imageprocessor.Params{MaxConcurrentProcessing: 1},
|
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
// A first request caches the photo as a source.
|
// A first request caches the photo as a source.
|
||||||
@@ -72,13 +79,15 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
cachedSource := widthOnlyRequest(fixtures, 50).Source
|
||||||
widthOnlyRequest(fixtures, 50))
|
|
||||||
if err != nil || contentHash == "" {
|
reader, _, _, err := svc.cache.Source(t.Context(), cachedSource)
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
if err != nil {
|
||||||
contentHash, err)
|
t.Fatalf("Source() error = %v; want the cached source", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
release := holdProcessingSlot(t, svc.processor)
|
release := holdProcessingSlot(t, svc.processor)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -98,7 +107,7 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
// before waiting, it would have read it by now.
|
// before waiting, it would have read it by now.
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
err = os.WriteFile(svc.cache.srcContent.hashToPath(contentHash),
|
err = os.WriteFile(sourceFilePath(svc, cachedSource.Key()),
|
||||||
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to rewrite the cached source: %v", err)
|
t.Fatalf("failed to rewrite the cached source: %v", err)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProxy_Get_ServesCachedVariantOfNegativeCachedURL caches a variant of
|
||||||
|
// the test photo, then keeps a failed fetch of the photo's URL in the negative
|
||||||
|
// cache, as a failed fetch for another variant does once the source has been
|
||||||
|
// evicted. The cached variant is still served, and a variant not in the cache
|
||||||
|
// is made from the cached source. Once the source is evicted, a variant not in
|
||||||
|
// the cache needs a fetch and is refused.
|
||||||
|
func TestProxy_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
err = svc.cache.PutFailure(t.Context(),
|
||||||
|
photoVariant(fixtures, 85, imagerequest.FitCover).Source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutFailure() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
if resp.CacheStatus != CacheHit {
|
||||||
|
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||||
|
resp.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
t.Logf("Get() of a variant made from the cached source: error = %v", err)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of a variant made from the cached source error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
|
||||||
|
|
||||||
|
reader, _, _, err := svc.cache.Source(t.Context(), source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Source() error = %v, want the cached source", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
// Evicts the source as the cache's eviction does: its row, then its file
|
||||||
|
_, err = svc.db.ExecContext(t.Context(),
|
||||||
|
`DELETE FROM source_content WHERE source_key = ?`, string(source.Key()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deleting the source's row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(sourceFilePath(svc, source.Key()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing the source's file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, imagerequest.FitCover))
|
||||||
|
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrNegativeCached) {
|
||||||
|
t.Errorf("Get() of a variant whose source was evicted error = %v, want %v",
|
||||||
|
err, ErrNegativeCached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxy_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// This test verifies that Proxy.Get() checks the negative cache
|
||||||
|
// We can't easily test the full pipeline without vips, but we can
|
||||||
|
// verify the error type
|
||||||
|
err := ErrNegativeCached
|
||||||
|
if !errors.Is(err, ErrNegativeCached) {
|
||||||
|
t.Error("ErrNegativeCached should be identifiable with errors.Is")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getImage calls Get for req and returns the image it answered with and
|
||||||
|
// its cache status.
|
||||||
|
func getImage(
|
||||||
|
t *testing.T, svc *testProxy, req *imagerequest.Request,
|
||||||
|
) ([]byte, CacheStatus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of quality %d error = %v", req.Transform.Quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the image of quality %d: %v", req.Transform.Quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, resp.CacheStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSize returns the size of the file at path.
|
||||||
|
func fileSize(t *testing.T, path string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedSourcePath returns the path of the cached source file req uses.
|
||||||
|
func cachedSourcePath(
|
||||||
|
t *testing.T, svc *testProxy, req *imagerequest.Request,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, _, _, err := svc.cache.Source(t.Context(), req.Source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Source() error = %v; want the cached source", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
return sourceFilePath(svc, req.Source.Key())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_FetchesAgainForRemovedCachedSource removes the cached
|
||||||
|
// source's row and file, as the reconciliation pass does when the file is
|
||||||
|
// empty or cut short. internal/imgcache tests that the pass does so. The
|
||||||
|
// test then asks for two new sizes of the source. The first must fetch it
|
||||||
|
// again and cache it whole, so the second is made from the cache without a
|
||||||
|
// third fetch.
|
||||||
|
func TestProxy_Get_FetchesAgainForRemovedCachedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
|
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
// Removes the source as the reconciliation pass does: its row, then its file
|
||||||
|
_, err := svc.db.ExecContext(t.Context(),
|
||||||
|
`DELETE FROM source_content WHERE source_key = ?`, string(source.Key()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deleting the source's row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing the source's file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
||||||
|
|
||||||
|
fetches := readCounters(t, svc).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_FetchesAgainSourceOfNoSourceFormat replaces the cached
|
||||||
|
// source's file with text. The next request for a new size of the source
|
||||||
|
// must fetch it again and cache it whole.
|
||||||
|
func TestProxy_Get_FetchesAgainSourceOfNoSourceFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"),
|
||||||
|
imgcache.StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replacing the source's file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
|
||||||
|
fetches := readCounters(t, svc).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of
|
||||||
|
// the photo and removes its row and file, as the reconciliation pass does
|
||||||
|
// when the file is empty or cut short. internal/imgcache tests that the
|
||||||
|
// pass does so. The test then checks that Get converts the image again, and
|
||||||
|
// that the next Get serves the whole variant from the cache.
|
||||||
|
func TestProxy_Get_ConvertsAgainForRemovedCachedVariant(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
key := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
// Removes the variant as the reconciliation pass does: its row, then its file
|
||||||
|
_, err := svc.db.ExecContext(t.Context(),
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deleting the variant's row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(variantFilePath(svc, key))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removing the variant's file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the removal: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the removal: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"converted again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
t.Logf("next request: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingAccessTimesWait is how long a test waits for the database's
|
||||||
|
// connection, for a cache hit to be answered, for an access time to reach
|
||||||
|
// the database, or for a request to return past its deadline.
|
||||||
|
const pendingAccessTimesWait = 5 * time.Second
|
||||||
|
|
||||||
|
// holdDatabase takes the one connection of db, so that every other query
|
||||||
|
// waits for it, and returns the func that frees it.
|
||||||
|
func holdDatabase(t *testing.T, db *sql.DB) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), pendingAccessTimesWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() { _ = conn.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
||||||
|
// database's one connection while a request whose fetch is held reaches its
|
||||||
|
// deadline. The request must return with the deadline's error while the
|
||||||
|
// connection is still held, as it waits for no database write past its
|
||||||
|
// deadline.
|
||||||
|
func TestProxy_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||||
|
|
||||||
|
svc.cache.StartPendingAccessTimeWrites()
|
||||||
|
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||||
|
|
||||||
|
// Room for the request to reach its fetch on a busy host
|
||||||
|
const timeout = 2 * time.Second
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
|
||||||
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
|
// The request has made its database reads by the time it fetches.
|
||||||
|
select {
|
||||||
|
case <-fetcher.started:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
t.Fatal("request did not reach its fetch by its deadline")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.db)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
t.Logf("Get() returned %v after its deadline, error = %v",
|
||||||
|
time.Since(deadline), got.err)
|
||||||
|
|
||||||
|
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Until(deadline) + pendingAccessTimesWait):
|
||||||
|
t.Fatal("request did not return while the database was busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProxy_Get_CountsStats walks Get through a miss that fetches the
|
||||||
|
// source, a hit, a miss that reuses the cached source, and two misses whose
|
||||||
|
// source cannot be used, checking every counter of the proxy and its cache
|
||||||
|
// after each.
|
||||||
|
func TestProxy_Get_CountsStats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
// NewTestFS builds the same files the test proxy's fetcher serves.
|
||||||
|
testFS, _ := NewTestFS(t)
|
||||||
|
|
||||||
|
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
|
||||||
|
|
||||||
|
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
||||||
|
steps := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
size int
|
||||||
|
wantErr bool
|
||||||
|
want counterValues
|
||||||
|
}{
|
||||||
|
{"miss that fetches the source", testPathPhoto, 50, false,
|
||||||
|
counterValues{0, 1, 1, photoBytes, 1}},
|
||||||
|
{"hit", testPathPhoto, 50, false,
|
||||||
|
counterValues{1, 1, 1, photoBytes, 1}},
|
||||||
|
{"miss that reuses the cached source", testPathPhoto, 25, false,
|
||||||
|
counterValues{1, 2, 1, photoBytes, 2}},
|
||||||
|
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
|
||||||
|
counterValues{1, 3, 2, photoBytes + fakeBytes, 2}},
|
||||||
|
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
|
||||||
|
counterValues{1, 4, 2, photoBytes + fakeBytes, 2}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
resp, err := svc.Get(t.Context(), &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: step.path,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: step.size, Height: step.size},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (err != nil) != step.wantErr {
|
||||||
|
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readCounters(t, svc)
|
||||||
|
if got != step.want {
|
||||||
|
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeUpstream answers every fetch with itself as a JPEG body. The body
|
||||||
|
// serves data, then calls cancel, when set, and returns err; io.EOF ends
|
||||||
|
// the body normally.
|
||||||
|
type fakeUpstream struct {
|
||||||
|
data *bytes.Reader
|
||||||
|
cancel context.CancelFunc
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *fakeUpstream) Fetch(
|
||||||
|
context.Context, imagerequest.Source, string, string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
|
return &httpfetcher.FetchResult{
|
||||||
|
Content: io.NopCloser(u),
|
||||||
|
ContentLength: -1,
|
||||||
|
ContentType: testContentTypeJPEG,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *fakeUpstream) Read(p []byte) (int, error) {
|
||||||
|
if u.data.Len() > 0 {
|
||||||
|
return u.data.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.cancel != nil {
|
||||||
|
u.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, u.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_CountsInterruptedMisses checks every counter of the proxy
|
||||||
|
// and its cache after a miss whose request context ends during or after the
|
||||||
|
// upstream fetch, and after a miss whose upstream body is over the size
|
||||||
|
// limit.
|
||||||
|
func TestProxy_Get_CountsInterruptedMisses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||||
|
half := len(photo) / 2
|
||||||
|
|
||||||
|
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
served int // bytes of the photo the upstream body serves
|
||||||
|
cancel bool // whether the body then ends the request context
|
||||||
|
readErr error // what the body then returns
|
||||||
|
wantErr bool
|
||||||
|
want counterValues
|
||||||
|
}{
|
||||||
|
{"request context ends during the fetch", half, true, context.Canceled, true,
|
||||||
|
counterValues{0, 1, 1, int64(half), 0}},
|
||||||
|
{"request context ends after the fetch", len(photo), true, io.EOF, false,
|
||||||
|
counterValues{0, 1, 1, int64(len(photo)), 1}},
|
||||||
|
{"upstream body over the size limit", half, false,
|
||||||
|
httpfetcher.ErrResponseTooLarge, true,
|
||||||
|
counterValues{0, 1, 1, int64(half), 0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
upstream := &fakeUpstream{
|
||||||
|
data: bytes.NewReader(photo[:tc.served]),
|
||||||
|
err: tc.readErr,
|
||||||
|
}
|
||||||
|
if tc.cancel {
|
||||||
|
upstream.cancel = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.fetcher = upstream
|
||||||
|
|
||||||
|
resp, err := svc.Get(ctx, &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
t.Logf("Get() error = %v", err)
|
||||||
|
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readCounters(t, svc)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("counters = %+v, want %+v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_CountsHitAfterRequestEnds checks every counter of the
|
||||||
|
// proxy and its cache after a hit served with a request context that has
|
||||||
|
// already ended: only the hit count moves.
|
||||||
|
func TestProxy_Get_CountsHitAfterRequestEnds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{
|
||||||
|
Host: fixtures.GoodHost,
|
||||||
|
Path: testPathPhoto,
|
||||||
|
},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A first request caches the variant.
|
||||||
|
resp, err := svc.Get(t.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
want := readCounters(t, svc)
|
||||||
|
want.hitCount++
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
resp, err = svc.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() with an ended request context: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
if resp.CacheStatus != CacheHit {
|
||||||
|
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readCounters(t, svc)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package imageproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared test data literals, extracted as constants for goconst.
|
||||||
|
const (
|
||||||
|
testContentTypeJPEG = "image/jpeg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
||||||
|
// produces, so injected timestamps compare correctly against ones the
|
||||||
|
// cache writes.
|
||||||
|
const sqliteTimestampFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
|
// TestFixtures contains paths to test files in the mock filesystem.
|
||||||
|
type TestFixtures struct {
|
||||||
|
// Valid image files
|
||||||
|
GoodHostJPEG string // valid JPEG on GoodHost
|
||||||
|
GoodHostPNG string // valid PNG on GoodHost
|
||||||
|
GoodHostGIF string // valid GIF on GoodHost
|
||||||
|
OtherHostJPEG string // valid JPEG on OtherHost
|
||||||
|
OtherHostPNG string // valid PNG on OtherHost
|
||||||
|
|
||||||
|
// Invalid/edge case files
|
||||||
|
InvalidFile string // file with wrong magic bytes
|
||||||
|
EmptyFile string // zero-byte file
|
||||||
|
TextFile string // text file masquerading as image
|
||||||
|
|
||||||
|
// Hostnames
|
||||||
|
GoodHost string // a host the mock fetcher serves
|
||||||
|
OtherHost string // a second host the mock fetcher serves
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultFixtures returns the standard test fixture paths.
|
||||||
|
func DefaultFixtures() *TestFixtures {
|
||||||
|
return &TestFixtures{
|
||||||
|
GoodHostJPEG: "goodhost.example.com/images/photo.jpg",
|
||||||
|
GoodHostPNG: "goodhost.example.com/images/logo.png",
|
||||||
|
GoodHostGIF: "goodhost.example.com/images/animation.gif",
|
||||||
|
OtherHostJPEG: "otherhost.example.com/uploads/image.jpg",
|
||||||
|
OtherHostPNG: "otherhost.example.com/uploads/icon.png",
|
||||||
|
InvalidFile: "goodhost.example.com/images/fake.jpg",
|
||||||
|
EmptyFile: "goodhost.example.com/images/empty.jpg",
|
||||||
|
TextFile: "goodhost.example.com/images/text.png",
|
||||||
|
GoodHost: "goodhost.example.com",
|
||||||
|
OtherHost: "otherhost.example.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestFS creates a mock filesystem with test images.
|
||||||
|
func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fixtures := DefaultFixtures()
|
||||||
|
|
||||||
|
// Generate test images
|
||||||
|
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||||
|
pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255})
|
||||||
|
gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255})
|
||||||
|
|
||||||
|
// Create the mock filesystem
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
// Good host files
|
||||||
|
fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData},
|
||||||
|
fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData},
|
||||||
|
fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData},
|
||||||
|
|
||||||
|
// Other host files
|
||||||
|
fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData},
|
||||||
|
fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData},
|
||||||
|
|
||||||
|
// Invalid files
|
||||||
|
fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")},
|
||||||
|
fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}},
|
||||||
|
fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")},
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockFS, fixtures
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestJPEG creates a minimal valid JPEG image.
|
||||||
|
func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestPNG creates a minimal valid PNG image.
|
||||||
|
func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestGIF creates a minimal valid GIF image.
|
||||||
|
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewPaletted(
|
||||||
|
image.Rect(0, 0, width, height),
|
||||||
|
[]color.Color{c, color.White},
|
||||||
|
)
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
img.SetColorIndex(x, y, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := gif.Encode(&buf, img, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test GIF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// testProxy is a Proxy with the database and the state directory of its
|
||||||
|
// cache, through which a test reads and changes the cache behind the proxy.
|
||||||
|
// registry is the metrics registry the proxy and its cache share.
|
||||||
|
type testProxy struct {
|
||||||
|
*Proxy
|
||||||
|
|
||||||
|
db *sql.DB
|
||||||
|
stateDir string
|
||||||
|
registry *prometheus.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestProxy creates a Proxy with mock fetcher for testing.
|
||||||
|
func setupTestProxy(t *testing.T) (*testProxy, *TestFixtures) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mockFS, fixtures := NewTestFS(t)
|
||||||
|
|
||||||
|
// Create temp directory for cache
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create in-memory database
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(db, imgcache.Config{
|
||||||
|
StateDir: tmpDir,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
MaxBytes: 1 << 30,
|
||||||
|
}, registry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy, err := New(&Config{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: httpfetcher.NewMock(mockFS),
|
||||||
|
Processor: imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 2,
|
||||||
|
}),
|
||||||
|
MaxResponseSize: 50 << 20,
|
||||||
|
}, registry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create proxy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testProxy{
|
||||||
|
Proxy: proxy,
|
||||||
|
db: db,
|
||||||
|
stateDir: tmpDir,
|
||||||
|
registry: registry,
|
||||||
|
}, fixtures
|
||||||
|
}
|
||||||
|
|
||||||
|
// counterValues holds the values of the counters of a proxy and its cache.
|
||||||
|
type counterValues struct {
|
||||||
|
hitCount int64
|
||||||
|
missCount int64
|
||||||
|
upstreamFetchCount int64
|
||||||
|
upstreamFetchBytes int64
|
||||||
|
transformCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCounters reads the counters of svc and its cache from their metrics
|
||||||
|
// registry.
|
||||||
|
func readCounters(t *testing.T, svc *testProxy) counterValues {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
families, err := svc.registry.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to gather the counters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make(map[string]int64, len(families))
|
||||||
|
|
||||||
|
for _, family := range families {
|
||||||
|
values[family.GetName()] = int64(family.GetMetric()[0].GetCounter().GetValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
return counterValues{
|
||||||
|
hitCount: values["pixa_cache_hits_total"],
|
||||||
|
missCount: values["pixa_cache_misses_total"],
|
||||||
|
upstreamFetchCount: values["pixa_upstream_fetches_total"],
|
||||||
|
upstreamFetchBytes: values["pixa_upstream_fetch_bytes_total"],
|
||||||
|
transformCount: values["pixa_transforms_total"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceFilePath returns the path of the file of the source key in svc's
|
||||||
|
// cache, as README.md gives it: <state_dir>/cache/sources/<ab>/<cd>/<key>.
|
||||||
|
func sourceFilePath(svc *testProxy, key imagerequest.SourceKey) string {
|
||||||
|
name := string(key)
|
||||||
|
|
||||||
|
return filepath.Join(svc.stateDir, "cache", "sources", name[0:2], name[2:4], name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantFilePath returns the path of the file of the variant key in svc's
|
||||||
|
// cache, as README.md gives it: <state_dir>/cache/variants/<ab>/<cd>/<key>.
|
||||||
|
func variantFilePath(svc *testProxy, key imagerequest.VariantKey) string {
|
||||||
|
name := string(key)
|
||||||
|
|
||||||
|
return filepath.Join(svc.stateDir, "cache", "variants", name[0:2], name[2:4], name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setVariantLastAccessed backdates the last access time of a tracked
|
||||||
|
// variant.
|
||||||
|
func setVariantLastAccessed(
|
||||||
|
t *testing.T, db *sql.DB, key imagerequest.VariantKey, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := db.ExecContext(t.Context(),
|
||||||
|
`UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set variant last_accessed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read affected rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("variant %s has no accounting row (affected=%d); "+
|
||||||
|
"stores must track variants in the database", key, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
|
// source content blob.
|
||||||
|
func setSourceLastAccessed(
|
||||||
|
t *testing.T, db *sql.DB, key imagerequest.SourceKey, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := db.ExecContext(t.Context(),
|
||||||
|
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read affected rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRows returns the number of rows the given query yields.
|
||||||
|
func countRows(t *testing.T, db *sql.DB, query string, args ...any) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(t.Context(), query, args...).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count query %q failed: %v", query, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestDB creates an in-memory SQLite database for testing
|
||||||
|
// using the production schema.
|
||||||
|
func setupTestDB(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the real production schema via migrations
|
||||||
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Package imagerequest holds the image request type, its parts, its checks,
|
||||||
|
// the parsing of the /v1/image/ path, the variant key, the source key and the
|
||||||
|
// source's upstream URL. It also holds the image format type and the MIME type
|
||||||
|
// of each format. It imports no other package of this repo, so every package
|
||||||
|
// that handles an image request can import it.
|
||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
|
// Format is an image format. pixa reads and writes the formats from JPEG to
|
||||||
|
// GIF below. In a request, orig and auto stand for one of them.
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
// Supported image formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal Format = "orig"
|
||||||
|
FormatJPEG Format = "jpeg"
|
||||||
|
FormatPNG Format = "png"
|
||||||
|
FormatWebP Format = "webp"
|
||||||
|
FormatAVIF Format = "avif"
|
||||||
|
FormatJXL Format = "jxl"
|
||||||
|
FormatGIF Format = "gif"
|
||||||
|
|
||||||
|
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
|
||||||
|
// request from its Accept header once the URL's signature or token has
|
||||||
|
// been checked; it is never processed or cached as itself.
|
||||||
|
FormatAuto Format = "auto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MIMEType returns the MIME type of the format, such as image/jpeg for
|
||||||
|
// FormatJPEG. orig and auto stand for another format, so they have none:
|
||||||
|
// MIMEType returns "" for them.
|
||||||
|
func (f Format) MIMEType() string {
|
||||||
|
switch f {
|
||||||
|
case FormatJPEG:
|
||||||
|
return "image/jpeg"
|
||||||
|
case FormatPNG:
|
||||||
|
return "image/png"
|
||||||
|
case FormatWebP:
|
||||||
|
return "image/webp"
|
||||||
|
case FormatAVIF:
|
||||||
|
return "image/avif"
|
||||||
|
case FormatJXL:
|
||||||
|
return "image/jxl"
|
||||||
|
case FormatGIF:
|
||||||
|
return "image/gif"
|
||||||
|
case FormatOriginal, FormatAuto:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size represents requested image dimensions
|
||||||
|
type Size struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// OriginalSize returns true if this represents "keep original size"
|
||||||
|
func (s Size) OriginalSize() bool {
|
||||||
|
return s.Width == 0 && s.Height == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit represents how to fit image into requested dimensions.
|
||||||
|
type Fit string
|
||||||
|
|
||||||
|
// Supported image fit modes.
|
||||||
|
const (
|
||||||
|
FitCover Fit = "cover"
|
||||||
|
FitContain Fit = "contain"
|
||||||
|
FitFill Fit = "fill"
|
||||||
|
FitInside Fit = "inside"
|
||||||
|
FitOutside Fit = "outside"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateFitMode checks if the given fit mode is valid.
|
||||||
|
// Returns ErrInvalidFitMode for unrecognized fit modes.
|
||||||
|
func ValidateFitMode(fit Fit) error {
|
||||||
|
switch fit {
|
||||||
|
case FitCover, FitContain, FitFill, FitInside, FitOutside, "":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrInvalidFitMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateImageRequest checks a request's width and height with
|
||||||
|
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
|
||||||
|
// /v1/image/ route and the encrypted /v1/e/ route validate through this
|
||||||
|
// function so a request from either source enforces identical bounds.
|
||||||
|
func ValidateImageRequest(req *Request) error {
|
||||||
|
err := ValidateDimension("width", req.Transform.Size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", req.Transform.Size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateFitMode(req.Transform.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request represents a request for a processed image: the source to fetch
|
||||||
|
// and the transform to apply to it. It holds exactly what the variant key
|
||||||
|
// covers.
|
||||||
|
type Request struct {
|
||||||
|
// Source is what to fetch
|
||||||
|
Source Source
|
||||||
|
// Transform is what to do to it
|
||||||
|
Transform Transform
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source is the image a request fetches from the origin.
|
||||||
|
type Source struct {
|
||||||
|
// Host is the origin host (e.g., "cdn.example.com")
|
||||||
|
Host string
|
||||||
|
// Path is the path on the origin with its percent-escapes decoded, as
|
||||||
|
// url.URL's Path holds it (e.g., "/photos/my cat.jpg")
|
||||||
|
Path string
|
||||||
|
// Query is the optional query string for the origin URL as the URL
|
||||||
|
// writes it, percent-escapes included, as url.URL's RawQuery holds it
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform is what a request does to its source; the image processor
|
||||||
|
// takes it alone.
|
||||||
|
type Transform struct {
|
||||||
|
// Size is the requested output dimensions
|
||||||
|
Size Size
|
||||||
|
// Format is the requested output format
|
||||||
|
Format Format
|
||||||
|
// Quality is the output quality (1-100) for lossy formats
|
||||||
|
Quality int
|
||||||
|
// FitMode is how to fit the image into requested dimensions
|
||||||
|
FitMode Fit
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL returns the source's upstream URL, with the scheme http when allowHTTP
|
||||||
|
// is true and https otherwise. net/url writes its path percent-encoded again,
|
||||||
|
// so each source path is one upstream path. Its query is the source query as
|
||||||
|
// it is, with the characters escapeQuery encodes percent-encoded.
|
||||||
|
func (s Source) URL(allowHTTP bool) *url.URL {
|
||||||
|
scheme := "https"
|
||||||
|
if allowHTTP {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: s.Host,
|
||||||
|
Path: s.Path,
|
||||||
|
RawQuery: escapeQuery(s.Query),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestFormat_MIMEType checks the MIME type of each format pixa reads and
|
||||||
|
// writes, and that orig and auto have none.
|
||||||
|
func TestFormat_MIMEType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
format Format
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{FormatJPEG, "image/jpeg"},
|
||||||
|
{FormatPNG, "image/png"},
|
||||||
|
{FormatWebP, "image/webp"},
|
||||||
|
{FormatAVIF, "image/avif"},
|
||||||
|
{FormatJXL, "image/jxl"},
|
||||||
|
{FormatGIF, "image/gif"},
|
||||||
|
{FormatOriginal, ""},
|
||||||
|
{FormatAuto, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := tt.format.MIMEType()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("%s MIMEType() = %q, want %q", tt.format, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceKey is a SHA256 hash identifying a cached source image (hex-encoded).
|
||||||
|
type SourceKey string
|
||||||
|
|
||||||
|
// Key returns the key the source is cached under, made as CacheKey makes a
|
||||||
|
// variant key.
|
||||||
|
// Format: sha256(host:path:query)
|
||||||
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
||||||
|
// either cannot be taken for the one between them.
|
||||||
|
func (s Source) Key() SourceKey {
|
||||||
|
data := fmt.Sprintf("%s:%s:%s",
|
||||||
|
s.Host,
|
||||||
|
escapePercentAndColon(s.Path),
|
||||||
|
escapePercentAndColon(s.Query),
|
||||||
|
)
|
||||||
|
h := sha256.Sum256([]byte(data))
|
||||||
|
|
||||||
|
return SourceKey(hex.EncodeToString(h[:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestSourceKey_DiffersForEachSource checks that each source gets a key of
|
||||||
|
// 64 hex characters, the same each time, and that no two of these sources
|
||||||
|
// share one, also when they differ only by a ":" or "%" in the path or query.
|
||||||
|
func TestSourceKey_DiffersForEachSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sources := []Source{
|
||||||
|
{Host: testHostCDN, Path: testPathCat},
|
||||||
|
{Host: testHostCDN, Path: testPathCat, Query: "v=2"},
|
||||||
|
{Host: "other.example.com", Path: testPathCat},
|
||||||
|
{Host: testHostCDN, Path: "/c:d"},
|
||||||
|
{Host: testHostCDN, Path: "/c", Query: "d:"},
|
||||||
|
{Host: testHostCDN, Path: "/c%3Ad"},
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[SourceKey]Source)
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
key := source.Key()
|
||||||
|
t.Logf("Key() of %+v = %s", source, key)
|
||||||
|
|
||||||
|
if len(key) != 64 {
|
||||||
|
t.Errorf("Key() of %+v length = %d, want 64", source, len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if again := source.Key(); again != key {
|
||||||
|
t.Errorf("Key() of %+v not deterministic: %s vs %s", source, key, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
if earlier, found := seen[key]; found {
|
||||||
|
t.Errorf("Key() gave %+v and %+v the same key", earlier, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = source
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSource_URL_DefaultHTTPS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: testPathCat,
|
||||||
|
Query: "v=2",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := source.URL(false).String()
|
||||||
|
|
||||||
|
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("URL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSource_URL_AllowHTTP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := Source{
|
||||||
|
Host: "localhost:8080",
|
||||||
|
Path: testPathCat,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := source.URL(true).String()
|
||||||
|
|
||||||
|
want := "http://localhost:8080/photos/cat.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("URL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSource_URL_AllowHTTPFalse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: "/img.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := source.URL(false).String()
|
||||||
|
if got != "https://cdn.example.com/img.jpg" {
|
||||||
|
t.Errorf("URL() = %q, want https scheme", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL parsing errors.
|
// URL parsing errors.
|
||||||
@@ -25,8 +23,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// MaxDimension is the maximum allowed width or height, the same as the image
|
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||||
// processor's maximum for an output image.
|
// processor's maximum for an output image (imageprocessor.MaxOutputDimension).
|
||||||
const MaxDimension = imageprocessor.MaxOutputDimension
|
const MaxDimension = 8192
|
||||||
|
|
||||||
// ValidateDimension checks one requested width or height; name ("width" or
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
// "height") appears in the error. 0 is valid: that side is then worked out from
|
// "height") appears in the error. 0 is valid: that side is then worked out from
|
||||||
@@ -68,42 +66,11 @@ var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
|
|||||||
var hostRegex = regexp.MustCompile(
|
var hostRegex = regexp.MustCompile(
|
||||||
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
||||||
|
|
||||||
// ParsedURL contains the parsed components of an image proxy URL.
|
|
||||||
type ParsedURL struct {
|
|
||||||
// Host is the source origin host (e.g., "cdn.example.com")
|
|
||||||
Host string
|
|
||||||
// Path is the path on the origin (e.g., "/photos/cat.jpg")
|
|
||||||
Path string
|
|
||||||
// Query is the optional query string for the origin
|
|
||||||
Query string
|
|
||||||
// Size is the requested output dimensions
|
|
||||||
Size Size
|
|
||||||
// Format is the requested output format
|
|
||||||
Format ImageFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
|
|
||||||
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
|
|
||||||
// Examples:
|
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
|
||||||
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
|
||||||
// - cdn.example.com/photos/cat.jpg/orig.png
|
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600
|
|
||||||
func ParseImagePath(path string) (*ParsedURL, error) {
|
|
||||||
// Strip leading slash if present (chi may include it)
|
|
||||||
path = strings.TrimPrefix(path, "/")
|
|
||||||
if path == "" {
|
|
||||||
return nil, ErrMissingHost
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseImageComponents(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
||||||
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
|
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
|
||||||
// for JPEG XL. A source query follows the source path after a "?", which the
|
// for JPEG XL. A source query follows the source path after a "?", which the
|
||||||
// URL writes as %3F.
|
// URL writes as %3F.
|
||||||
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
func ParseImageURL(urlPath string) (*Request, error) {
|
||||||
// Remove the /v1/image/ prefix
|
// Remove the /v1/image/ prefix
|
||||||
const prefix = "/v1/image/"
|
const prefix = "/v1/image/"
|
||||||
if !strings.HasPrefix(urlPath, prefix) {
|
if !strings.HasPrefix(urlPath, prefix) {
|
||||||
@@ -120,7 +87,7 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
|
|
||||||
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||||
// <host>/<path>/<size> for JPEG XL.
|
// <host>/<path>/<size> for JPEG XL.
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*Request, error) {
|
||||||
// Find the last path segment, which holds "size" or "size.format"
|
// Find the last path segment, which holds "size" or "size.format"
|
||||||
lastSlash := strings.LastIndex(remainder, "/")
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
if lastSlash == -1 {
|
if lastSlash == -1 {
|
||||||
@@ -159,15 +126,15 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
return nil, ErrMissingHost
|
return nil, ErrMissingHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
|
// Later steps put the host unparsed into the allowlist check, the cache key
|
||||||
// the host would make Go's URL parser, which the allowlist check and the
|
// and the upstream URL, so only the forms hostRegex describes are accepted
|
||||||
// fetcher use, read a different host from the one allowlisted and cached
|
|
||||||
hostMatch := hostRegex.FindStringSubmatch(host)
|
hostMatch := hostRegex.FindStringSubmatch(host)
|
||||||
if hostMatch == nil {
|
if hostMatch == nil {
|
||||||
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go's URL parser refuses brackets around anything but an IPv6 address
|
// The allowlist check and the fetcher drop the brackets, so brackets
|
||||||
|
// around anything but an IPv6 address would give a host a second spelling
|
||||||
if inBrackets := hostMatch[1]; inBrackets != "" {
|
if inBrackets := hostMatch[1]; inBrackets != "" {
|
||||||
addr, err := netip.ParseAddr(inBrackets)
|
addr, err := netip.ParseAddr(inBrackets)
|
||||||
if err != nil || !addr.Is6() {
|
if err != nil || !addr.Is6() {
|
||||||
@@ -180,9 +147,8 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
// host
|
// host
|
||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
// Go's URL parser, which the fetcher uses, reads a "#" as the start of a
|
// A "#" starts a URL's fragment, so neither the source path nor the source
|
||||||
// fragment and drops the rest, so each text after it would be another
|
// query may hold one
|
||||||
// spelling, cached under its own keys, of one upstream request
|
|
||||||
if strings.Contains(path, "#") {
|
if strings.Contains(path, "#") {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
`%w: the source path or query holds a "#"`, ErrInvalidPath)
|
`%w: the source path or query holds a "#"`, ErrInvalidPath)
|
||||||
@@ -209,12 +175,9 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
path = "/" + path
|
path = "/" + path
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ParsedURL{
|
return &Request{
|
||||||
Host: host,
|
Source: Source{Host: host, Path: path, Query: query},
|
||||||
Path: path,
|
Transform: Transform{Size: size, Format: format},
|
||||||
Query: query,
|
|
||||||
Size: size,
|
|
||||||
Format: format,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +260,7 @@ func escapeQuery(query string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||||
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
func parseSizeFormat(s string) (Size, Format, error) {
|
||||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
@@ -351,8 +314,8 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return size, format, nil
|
return size, format, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFormat converts a format string to ImageFormat.
|
// parseFormat converts a format string to Format.
|
||||||
func parseFormat(s string) (ImageFormat, error) {
|
func parseFormat(s string) (Format, error) {
|
||||||
switch strings.ToLower(s) {
|
switch strings.ToLower(s) {
|
||||||
case "orig", "original":
|
case "orig", "original":
|
||||||
return FormatOriginal, nil
|
return FormatOriginal, nil
|
||||||
@@ -374,14 +337,3 @@ func parseFormat(s string) (ImageFormat, error) {
|
|||||||
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToImageRequest converts a ParsedURL to an ImageRequest.
|
|
||||||
func (p *ParsedURL) ToImageRequest() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: p.Host,
|
|
||||||
SourcePath: p.Path,
|
|
||||||
SourceQuery: p.Query,
|
|
||||||
Size: p.Size,
|
|
||||||
Format: p.Format,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+79
-183
@@ -1,33 +1,41 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assertParsedURL compares all fields of a parsed URL against the
|
// Shared test fixture strings.
|
||||||
// expected value.
|
const (
|
||||||
func assertParsedURL(t *testing.T, got, want *ParsedURL) {
|
testHostCDN = "cdn.example.com"
|
||||||
|
testHostExample = "example.com"
|
||||||
|
testPathCat = "/photos/cat.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertRequest compares the source host, path and query and the size and
|
||||||
|
// format of got against want.
|
||||||
|
func assertRequest(t *testing.T, got, want *Request) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if got.Host != want.Host {
|
if got.Source.Host != want.Source.Host {
|
||||||
t.Errorf("Host = %q, want %q", got.Host, want.Host)
|
t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Path != want.Path {
|
if got.Source.Path != want.Source.Path {
|
||||||
t.Errorf("Path = %q, want %q", got.Path, want.Path)
|
t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Query != want.Query {
|
if got.Source.Query != want.Source.Query {
|
||||||
t.Errorf("Query = %q, want %q", got.Query, want.Query)
|
t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Size != want.Size {
|
if got.Transform.Size != want.Transform.Size {
|
||||||
t.Errorf("Size = %v, want %v", got.Size, want.Size)
|
t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Format != want.Format {
|
if got.Transform.Format != want.Transform.Format {
|
||||||
t.Errorf("Format = %q, want %q", got.Format, want.Format)
|
t.Errorf("Format = %q, want %q",
|
||||||
|
got.Transform.Format, want.Transform.Format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,62 +45,64 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want *ParsedURL
|
want *Request
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic path with size",
|
name: "basic path with size",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat,
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with 0x0",
|
name: "original size with 0x0",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat,
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
Size: Size{Width: 0, Height: 0}, Format: FormatJPEG,
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with orig keyword",
|
name: "original size with orig keyword",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat,
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||||
Size: Size{Width: 0, Height: 0}, Format: FormatPNG,
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "path with query string",
|
name: "path with query string",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
Source: Source{
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||||
|
},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deep nested path",
|
name: "deep nested path",
|
||||||
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: "/a/b/c/d/image.jpg",
|
Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
|
||||||
Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF,
|
Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "jpg alias for jpeg",
|
name: "jpg alias for jpeg",
|
||||||
input: "/v1/image/example.com/img.png/100x100.jpg",
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostExample, Path: "/img.png",
|
Source: Source{Host: testHostExample, Path: "/img.png"},
|
||||||
Size: Size{Width: 100, Height: 100}, Format: FormatJPEG,
|
Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gif format",
|
name: "gif format",
|
||||||
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostExample, Path: "/animated.gif",
|
Source: Source{Host: testHostExample, Path: "/animated.gif"},
|
||||||
Size: Size{Width: 200, Height: 200}, Format: FormatGIF,
|
Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -106,7 +116,7 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertParsedURL(t, got, tt.want)
|
assertRequest(t, got, tt.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,8 +131,8 @@ func TestParseImageURL_AutoFormat(t *testing.T) {
|
|||||||
t.Fatalf("ParseImageURL() error = %v", err)
|
t.Fatalf("ParseImageURL() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Format != FormatAuto {
|
if got.Transform.Format != FormatAuto {
|
||||||
t.Errorf("Format = %q, want %q", got.Format, FormatAuto)
|
t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,135 +192,49 @@ func TestParseImageURL_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath(t *testing.T) {
|
// TestParseImageURL_LowerCasesHost verifies that the host is lower-cased, so
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want *ParsedURL
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "chi wildcard capture",
|
|
||||||
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
||||||
want: &ParsedURL{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with leading slash from chi",
|
|
||||||
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
||||||
want: &ParsedURL{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got, err := ParseImagePath(tt.input)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assertParsedURL(t, got, tt.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
|
||||||
// that every spelling of its letter case is the same host to the allowlist,
|
// that every spelling of its letter case is the same host to the allowlist,
|
||||||
// the upstream connection limits and the cache, and that the path keeps its
|
// the upstream connection limits and the cache, and that the path keeps its
|
||||||
// case.
|
// case.
|
||||||
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
func TestParseImageURL_LowerCasesHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
got, err := ParseImageURL("/v1/image/CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseImagePath() error = %v", err)
|
t.Fatalf("ParseImageURL() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("parsed host %q, path %q", got.Host, got.Path)
|
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
||||||
|
|
||||||
if got.Host != testHostCDN {
|
if got.Source.Host != testHostCDN {
|
||||||
t.Errorf("Host = %q, want %q", got.Host, testHostCDN)
|
t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.Path != "/Photos/Cat.jpg" {
|
if got.Source.Path != "/Photos/Cat.jpg" {
|
||||||
t.Errorf("Path = %q, want %q", got.Path, "/Photos/Cat.jpg")
|
t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
// TestParseImageURL_RefusesUserinfo verifies that a host holding "@" is
|
||||||
// refused: a URL reads what comes before the "@" as a user name and password
|
// refused: a URL reads what comes before the "@" as a user name and password
|
||||||
// and the rest as the host.
|
// and the rest as the host.
|
||||||
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
func TestParseImageURL_RefusesUserinfo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, input := range []string{
|
for _, input := range []string{
|
||||||
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"/v1/image/user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"/v1/image/user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
} {
|
} {
|
||||||
got, err := ParseImagePath(input)
|
got, err := ParseImageURL(input)
|
||||||
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
|
||||||
|
|
||||||
if !errors.Is(err, ErrInvalidHost) {
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
t.Errorf("ParseImageURL(%q) error = %v, want %v",
|
||||||
input, err, ErrInvalidHost)
|
input, err, ErrInvalidHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsedURL_ToImageRequest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parsed := &ParsedURL{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
Query: "version=2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
if req.SourceHost != parsed.Host {
|
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.SourcePath != parsed.Path {
|
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.SourceQuery != parsed.Query {
|
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Size != parsed.Size {
|
|
||||||
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Format != parsed.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseImageURL_PathTraversal(t *testing.T) {
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -331,6 +255,10 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
name: "parent in middle of path",
|
name: "parent in middle of path",
|
||||||
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "parent directory in path",
|
||||||
|
input: "/v1/image/cdn.example.com/photos/../secret/800x600.jpeg",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "encoded parent directory",
|
name: "encoded parent directory",
|
||||||
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
||||||
@@ -373,40 +301,6 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath_PathTraversal(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Test path traversal via ParseImagePath (chi wildcard)
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "parent directory in path",
|
|
||||||
input: "cdn.example.com/photos/../secret/800x600.jpeg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "encoded traversal",
|
|
||||||
input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := ParseImagePath(tt.input)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("ParseImagePath() should reject path traversal attempts")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrPathTraversal) {
|
|
||||||
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
||||||
// a source query holding %25, the percent-escape of a literal "%", are
|
// a source query holding %25, the percent-escape of a literal "%", are
|
||||||
// accepted as they are, although decoding them once more leaves a "%" that
|
// accepted as they are, although decoding them once more leaves a "%" that
|
||||||
@@ -417,22 +311,24 @@ func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want *ParsedURL
|
want *Request
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "path",
|
name: "path",
|
||||||
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: "/sale/50%25off.jpg",
|
Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "query",
|
name: "query",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &Request{
|
||||||
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
Source: Source{
|
||||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
||||||
|
},
|
||||||
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -446,7 +342,7 @@ func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
|||||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertParsedURL(t, got, tt.want)
|
assertRequest(t, got, tt.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-11
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,35 +10,43 @@ func TestValidateImageRequest(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
req ImageRequest
|
req Request
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "within bounds",
|
name: "within bounds",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, FitMode: FitCover,
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size and empty fit",
|
name: "original size and empty fit",
|
||||||
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
req: Request{Transform: Transform{Size: Size{Width: 0, Height: 0}}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "width over limit",
|
name: "width over limit",
|
||||||
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: MaxDimension + 1, Height: 600},
|
||||||
|
}},
|
||||||
wantErr: ErrDimensionTooLarge,
|
wantErr: ErrDimensionTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "height over limit",
|
name: "height over limit",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: MaxDimension + 1},
|
||||||
|
}},
|
||||||
wantErr: ErrDimensionTooLarge,
|
wantErr: ErrDimensionTooLarge,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "negative width",
|
name: "negative width",
|
||||||
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
req: Request{Transform: Transform{Size: Size{Width: -1, Height: 600}}},
|
||||||
wantErr: ErrInvalidSize,
|
wantErr: ErrInvalidSize,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid fit mode",
|
name: "invalid fit mode",
|
||||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
req: Request{Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, FitMode: "bogus",
|
||||||
|
}},
|
||||||
wantErr: ErrInvalidFitMode,
|
wantErr: ErrInvalidFitMode,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
|
||||||
|
type VariantKey string
|
||||||
|
|
||||||
|
// CacheKey generates a unique key for a request variant.
|
||||||
|
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
|
||||||
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
||||||
|
// either cannot be taken for the one between them.
|
||||||
|
func CacheKey(req *Request) VariantKey {
|
||||||
|
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
|
||||||
|
req.Source.Host,
|
||||||
|
escapePercentAndColon(req.Source.Path),
|
||||||
|
escapePercentAndColon(req.Source.Query),
|
||||||
|
req.Transform.Size.Width,
|
||||||
|
req.Transform.Size.Height,
|
||||||
|
req.Transform.Format,
|
||||||
|
req.Transform.Quality,
|
||||||
|
req.Transform.FitMode,
|
||||||
|
)
|
||||||
|
h := sha256.Sum256([]byte(data))
|
||||||
|
|
||||||
|
return VariantKey(hex.EncodeToString(h[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapePercentAndColon returns s with each "%" written as "%25" and each
|
||||||
|
// ":" as "%3A", so that the result holds no ":".
|
||||||
|
func escapePercentAndColon(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "%", "%25")
|
||||||
|
|
||||||
|
return strings.ReplaceAll(s, ":", "%3A")
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCacheKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req1 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same request should produce same key
|
||||||
|
key1 := CacheKey(req1)
|
||||||
|
key2 := CacheKey(req2)
|
||||||
|
|
||||||
|
if key1 != key2 {
|
||||||
|
t.Errorf("CacheKey() not deterministic: %s vs %s", key1, key2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key should be 64 hex chars
|
||||||
|
if len(string(key1)) != 64 {
|
||||||
|
t.Errorf("CacheKey() length = %d, want 64", len(string(key1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different size should produce different key
|
||||||
|
req3 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 400, Height: 300}, Format: FormatWebP, // Different size
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
key3 := CacheKey(req3)
|
||||||
|
if key1 == key3 {
|
||||||
|
t.Error("CacheKey() produced same key for different sizes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different format should produce different key
|
||||||
|
req4 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatPNG, // Different format
|
||||||
|
Quality: 85, FitMode: FitCover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
key4 := CacheKey(req4)
|
||||||
|
if key1 == key4 {
|
||||||
|
t.Error("CacheKey() produced same key for different formats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different quality should produce different key
|
||||||
|
req5 := &Request{
|
||||||
|
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
|
Quality: 50, FitMode: FitCover, // Different quality
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
key5 := CacheKey(req5)
|
||||||
|
if key1 == key5 {
|
||||||
|
t.Error("CacheKey() produced same key for different quality")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
|
||||||
|
// URLs whose path or query holds ":" or "%" get different keys.
|
||||||
|
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path1, query1 string
|
||||||
|
path2, query2 string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "colon moved from path to query",
|
||||||
|
path1: "/a:b", query1: "",
|
||||||
|
path2: "/a", query2: "b:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colon in path and its percent-encoding",
|
||||||
|
path1: "/a:b", query1: "",
|
||||||
|
path2: "/a%3Ab", query2: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key1 := CacheKey(&Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: tt.path1,
|
||||||
|
Query: tt.query1,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
key2 := CacheKey(&Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: tt.path2,
|
||||||
|
Query: tt.query2,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
|
||||||
|
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
|
||||||
|
|
||||||
|
if key1 == key2 {
|
||||||
|
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
|
||||||
|
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+546
-590
File diff suppressed because it is too large.
Load diff
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user