Compare commits
1
Commits
next
..
0742446199
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0742446199 |
No files matched your search
@@ -70,3 +70,4 @@ vendor/
|
|||||||
# Local dev configs
|
# Local dev configs
|
||||||
config.yml
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
|
config.dev.yml
|
||||||
@@ -1,76 +1,75 @@
|
|||||||
# Repository Rules
|
# Repository Rules
|
||||||
|
|
||||||
Last Updated 2026-10-27
|
Last Updated 2026-01-08
|
||||||
|
|
||||||
These rules MUST be followed at all times. This is very important.
|
These rules MUST be followed at all times, it is very important.
|
||||||
|
|
||||||
- Never use `git add -A`. Stage specific changes for a deliberate commit. A
|
- Never use `git add -A` - add specific changes to a deliberate commit. A commit
|
||||||
commit holds one change. After each change, make a commit with a good one-line
|
should contain one change. After each change, make a commit with a good
|
||||||
summary.
|
one-line summary.
|
||||||
|
|
||||||
- NEVER modify the linter config without asking first.
|
- NEVER modify the linter config without asking first.
|
||||||
|
|
||||||
- NEVER modify a test to exclude special cases or otherwise make it pass without
|
- NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||||
asking first. In almost all cases, change the code, NOT the test. You need
|
without asking first. In almost all cases, the code should be changed, NOT the
|
||||||
explicit user approval to modify an existing test. If you think a test needs
|
tests. If you think the test needs to be changed, make your case for that and
|
||||||
to change, make your case and ask for permission to proceed. Then stop. A bug
|
ask for permission to proceed, then stop. You need explicit user approval to
|
||||||
fix may change an existing test without asking only when that test pins
|
modify existing tests. A bug fix may change an existing test without asking
|
||||||
exactly the behavior the filed bug calls wrong. The change must then move the
|
when that test pins exactly the behavior the filed bug calls wrong, and the
|
||||||
test to the fixed behavior and nothing else. Any other change to an existing
|
change moves it to the fixed behavior and nothing else; any other change to an
|
||||||
test needs explicit user approval. Writing NEW tests needs no approval.
|
existing test still needs explicit user approval. (You do not need user
|
||||||
|
approval for writing NEW tests.)
|
||||||
|
|
||||||
- When linting, assume the linter config is CORRECT. Treat each item the linter
|
- When linting, assume the linter config is CORRECT, and that each item output
|
||||||
reports as something that legitimately needs fixing in the code.
|
by the linter is something that legitimately needs fixing in the code.
|
||||||
|
|
||||||
- When running tests, use `make test`.
|
- When running tests, use `make test`.
|
||||||
|
|
||||||
- Before committing, run `make check`. It runs `make test`, `make lint` and
|
- Before commits, run `make check`. This runs `make lint` and `make test` and
|
||||||
`make fmt-check`. Any issue it finds MUST be resolved before committing,
|
`make check-fmt`. Any issues discovered MUST be resolved before committing
|
||||||
unless you are explicitly told otherwise.
|
unless 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 make sure the test is written correctly. Commit that.
|
logging to the test to ensure it is written correctly. Commit that. Then go
|
||||||
Then work on the fix until the test passes, without changing the test further.
|
about fixing the bug until the test passes (without modifying the test
|
||||||
Then commit the fix.
|
further). Then commit that.
|
||||||
|
|
||||||
- When adding a new feature, do the same: write a test first (test-driven
|
- When adding a new feature, do the same - implement a test first (TDD). It
|
||||||
development, TDD). The test does not need to be very complex. Commit the test,
|
doesn't have to be super complex. Commit the test, then commit the feature.
|
||||||
then commit the feature.
|
|
||||||
|
|
||||||
- When adding a new feature, use a feature branch. The feature branch reaches
|
- When adding a new feature, use a feature branch. When the feature is
|
||||||
`next` through a PR that passes review. It is merged only when the feature is
|
completely finished and the code is up to standards (passes `make check`) then
|
||||||
completely finished and the code is up to standards. The code is up to
|
and only then can the feature branch be merged into `main` and the branch
|
||||||
standards when it passes `make check`. The branch is deleted after the merge.
|
deleted.
|
||||||
Only the user merges `next` into `main`.
|
|
||||||
|
|
||||||
- Write `godoc` comments for all exported types and functions as you go along.
|
- Write godoc documentation comments for all exported types and functions as you
|
||||||
|
go along.
|
||||||
|
|
||||||
- ALWAYS be consistent in naming. If you name something one way in one place,
|
- ALWAYS be consistent in naming. If you name something one thing in one place,
|
||||||
use the EXACT SAME name in every other place.
|
name it the EXACT SAME THING in another place.
|
||||||
|
|
||||||
- Be descriptive and specific in naming. `al` is bad; `SourceHostAllowlist` is
|
- Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
|
||||||
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
|
||||||
|
|
||||||
- This is not prototype or teaching code. It is designed for production. Any
|
- This is not prototype or teaching code - this is designed for production. Any
|
||||||
security issue (such as denial of service) or other web vulnerability is a P1
|
security issues (such as denial of service) or other web vulnerabilities are
|
||||||
bug. It must be added at the top of `TODO.md`.
|
P1 bugs and must be added to TODO.md at the top.
|
||||||
|
|
||||||
- Do not stub an implementation unless you are specifically instructed to. This
|
- As this is production code, no stubbing of implementations unless specifically
|
||||||
is production code, and we need working implementations.
|
instructed. We need working implementations.
|
||||||
|
|
||||||
- NEVER silently fall back to a different setting when a user's parameter
|
- NEVER silently fall back to a different setting when a user's parameter
|
||||||
explicitly specifies a value. For example, if a user requests `format=webp`
|
explicitly specifies a value. If a user requests format=webp and WebP encoding
|
||||||
and WebP encoding is not supported, return an error. Do NOT silently output
|
is not supported, return an error - do NOT silently output PNG instead. If a
|
||||||
PNG instead. If a user specifies `fit=invalid` and that fit mode does not
|
user specifies fit=invalid and that fit mode doesn't exist, return an error -
|
||||||
exist, return an error. Do NOT silently default to `cover`. A silent fallback
|
do NOT silently default to "cover". Silent fallbacks violate the principle of
|
||||||
violates the principle of least surprise and masks bugs. Defaults are
|
least surprise and mask bugs. The only acceptable defaults are for OMITTED
|
||||||
acceptable only for OMITTED parameters, never for INVALID explicit values.
|
parameters, never for INVALID explicit values.
|
||||||
|
|
||||||
- Avoid vendoring dependencies unless specifically instructed to. NEVER commit
|
- Avoid vendoring deps unless specifically instructed to. NEVER commit the
|
||||||
the `vendor/` directory. NEVER commit compiled binaries. If such a directory
|
vendor directory, NEVER commit compiled binaries. If these directories or
|
||||||
or file exists and `.gitignore` does not list it, add it to `.gitignore`.
|
files exist, add them to .gitignore (and commit the .gitignore) if they are
|
||||||
Commit that `.gitignore` change. Keep the entire git repository, history
|
not already in there. Keep the entire git repository (with history) small -
|
||||||
included, under 20 MiB. Go over 20 MiB only when you specifically must commit
|
under 20MiB, unless you specifically must commit larger files (e.g. test
|
||||||
a large file, such as a media file used as a test fixture. Only OUR source
|
fixture example media files). Only OUR source code and immediately supporting
|
||||||
code and its immediately supporting files, such as test examples, go into the
|
files (such as test examples) goes into the repo/history.
|
||||||
repository and its history.
|
|
||||||
@@ -79,15 +79,12 @@ docker-versioned:
|
|||||||
docker-test:
|
docker-test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
# Run local dev server in Docker, with debug on and a new random signing
|
# Run local dev server in Docker
|
||||||
# key each run. Log in to the page with the key it prints.
|
|
||||||
devserver: docker-versioned devserver-stop
|
devserver: docker-versioned devserver-stop
|
||||||
@key="$$(head -c 32 /dev/urandom | base64)" && \
|
|
||||||
docker run -d --name pixad-dev -p 8080:8080 \
|
docker run -d --name pixad-dev -p 8080:8080 \
|
||||||
-e PIXA_SIGNING_KEY="$$key" -e PIXA_DEBUG=true \
|
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
||||||
pixa:latest >/dev/null && \
|
pixa:latest
|
||||||
echo "pixad running at http://localhost:8080" && \
|
@echo "pixad running at http://localhost:8080"
|
||||||
echo "signing key: $$key"
|
|
||||||
|
|
||||||
# Stop dev server
|
# Stop dev server
|
||||||
devserver-stop:
|
devserver-stop:
|
||||||
|
|||||||
+2
-13
@@ -1,21 +1,10 @@
|
|||||||
// Package main is the entry point for the pixad image proxy server. pixad
|
// Package main is the entry point for the pixad image proxy server.
|
||||||
// also runs as a child process of itself, to read the size of an SVG source
|
|
||||||
// or to render it (see imageprocessor.RunAsSVGChildProcess).
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "sneak.berlin/go/pixa/internal/app"
|
||||||
"os"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/app"
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Version string //nolint:gochecknoglobals // set by ldflags
|
var Version string //nolint:gochecknoglobals // set by ldflags
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if imageprocessor.IsSVGChildProcess(os.Args[1:]) {
|
|
||||||
os.Exit(imageprocessor.RunAsSVGChildProcess(os.Args[1:]))
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Run(Version)
|
app.Run(Version)
|
||||||
}
|
}
|
||||||
@@ -158,9 +158,8 @@ access_control_allow_origin: "*"
|
|||||||
# Sentry DSN for error reporting (default: empty, which turns it off)
|
# Sentry DSN for error reporting (default: empty, which turns it off)
|
||||||
sentry_dsn: ""
|
sentry_dsn: ""
|
||||||
|
|
||||||
# Username and password for /metrics, set together (default: unset). pixa
|
# Username and password for /metrics, set together (default: unset). Metrics
|
||||||
# serves /metrics, and measures HTTP requests, only when both are set. It
|
# are measured and /metrics is served only when both are set.
|
||||||
# measures the pixa_ metrics either way.
|
|
||||||
# metrics:
|
# metrics:
|
||||||
# username: "admin"
|
# username: "admin"
|
||||||
# password: "secret"
|
# password: "secret"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ require (
|
|||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
go.uber.org/dig v1.19.0
|
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
@@ -127,6 +126,7 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||||
|
go.uber.org/dig v1.19.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
|||||||
@@ -72,3 +72,13 @@ 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,3 +139,87 @@ 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-48
@@ -2,16 +2,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/dig"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
@@ -20,7 +16,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,10 +51,7 @@ func run(_ *cobra.Command, _ []string) {
|
|||||||
// A write to a closed stdout or stderr must not end the process.
|
// A write to a closed stdout or stderr must not end the process.
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
|
|
||||||
app := fx.New(
|
fx.New(
|
||||||
// pixad logs with slog only; runApp prints a startup or shutdown
|
|
||||||
// error itself.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
config.New,
|
config.New,
|
||||||
database.New,
|
database.New,
|
||||||
@@ -69,48 +61,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) {},
|
||||||
),
|
),
|
||||||
)
|
).Run()
|
||||||
|
|
||||||
exitCode := runApp(app, os.Stderr)
|
|
||||||
if exitCode != 0 {
|
|
||||||
os.Exit(exitCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runApp starts app, waits for a signal or a shutdown request, stops app,
|
|
||||||
// and returns the status pixad exits with, as fx.App.Run does. It prints
|
|
||||||
// a start or stop error to stderr, once, as the error that caused it:
|
|
||||||
// dig.RootCause drops the frames fx and dig wrap around it.
|
|
||||||
func runApp(app *fx.App, stderr io.Writer) int {
|
|
||||||
startCtx, cancelStart := context.WithTimeout(context.Background(), app.StartTimeout())
|
|
||||||
defer cancelStart()
|
|
||||||
|
|
||||||
err := app.Start(startCtx)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(stderr, dig.RootCause(err))
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdown := <-app.Wait()
|
|
||||||
|
|
||||||
stopCtx, cancelStop := context.WithTimeout(context.Background(), app.StopTimeout())
|
|
||||||
defer cancelStop()
|
|
||||||
|
|
||||||
err = app.Stop(stopCtx)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(stderr, dig.RootCause(err))
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return shutdown.ExitCode
|
|
||||||
}
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errTestStopFailed = errors.New("test stop hook failed")
|
|
||||||
|
|
||||||
// TestRunAppPrintsStopErrorOnce checks that when a stop hook fails, runApp
|
|
||||||
// prints the hook's error once and returns exit status 1.
|
|
||||||
func TestRunAppPrintsStopErrorOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Invoke(func(lifecycle fx.Lifecycle, shutdowner fx.Shutdowner) {
|
|
||||||
lifecycle.Append(fx.Hook{
|
|
||||||
// Ask to shut down as soon as the app has started, so
|
|
||||||
// that runApp goes on to stop it.
|
|
||||||
OnStart: func(context.Context) error { return shutdowner.Shutdown() },
|
|
||||||
OnStop: func(context.Context) error { return errTestStopFailed },
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
exitCode := runApp(app, &stderr)
|
|
||||||
if exitCode != 1 {
|
|
||||||
t.Errorf("runApp() = %d, want 1", exitCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := errTestStopFailed.Error() + "\n"
|
|
||||||
if stderr.String() != want {
|
|
||||||
t.Errorf("stderr = %q, want %q", stderr.String(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunAppReturnsShutdownExitCode checks that runApp returns the exit
|
|
||||||
// code a shutdown request names.
|
|
||||||
func TestRunAppReturnsShutdownExitCode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Invoke(func(lifecycle fx.Lifecycle, shutdowner fx.Shutdowner) {
|
|
||||||
lifecycle.Append(fx.Hook{
|
|
||||||
OnStart: func(context.Context) error {
|
|
||||||
return shutdowner.Shutdown(fx.ExitCode(3))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
exitCode := runApp(app, io.Discard)
|
|
||||||
if exitCode != 3 {
|
|
||||||
t.Errorf("runApp() = %d, want 3", exitCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package app_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/app"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests run pixad in a child process: the test binary itself,
|
|
||||||
// started again with runPixadInChildEnvVar set, which makes TestMain run
|
|
||||||
// pixad instead of the tests.
|
|
||||||
const runPixadInChildEnvVar = "TEST_RUN_PIXAD_IN_CHILD"
|
|
||||||
|
|
||||||
const validTestSigningKey = "test-signing-key-for-app-tests-0123456789"
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
if os.Getenv(runPixadInChildEnvVar) == "1" {
|
|
||||||
os.Args = []string{"pixad"}
|
|
||||||
|
|
||||||
app.Run("test")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
// pixadCommand returns a command that runs pixad with only the given
|
|
||||||
// environment, in an empty directory with an empty HOME, so that no
|
|
||||||
// config file is found.
|
|
||||||
func pixadCommand(t *testing.T, env ...string) (*exec.Cmd, *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
testBinary := os.Args[0]
|
|
||||||
cmd := exec.CommandContext(t.Context(), testBinary) //nolint:gosec // not tainted
|
|
||||||
cmd.Dir = t.TempDir()
|
|
||||||
// A test binary built with -cover warns on stderr at exit when
|
|
||||||
// GOCOVERDIR is not set.
|
|
||||||
cmd.Env = append([]string{
|
|
||||||
runPixadInChildEnvVar + "=1",
|
|
||||||
"HOME=" + t.TempDir(),
|
|
||||||
"GOCOVERDIR=" + t.TempDir(),
|
|
||||||
}, env...)
|
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
return cmd, &stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunPrintsStartupErrorOnce checks that an invalid setting is
|
|
||||||
// printed once, as the line that names it, and nothing else goes to
|
|
||||||
// stderr.
|
|
||||||
func TestRunPrintsStartupErrorOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cmd, stderr := pixadCommand(t, "PIXA_SIGNING_KEY=short")
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
|
|
||||||
var exitErr *exec.ExitError
|
|
||||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 {
|
|
||||||
t.Fatalf("pixad: got %v, want exit status 1; stderr:\n%s", err, stderr)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := `config key "signing_key" (environment variable PIXA_SIGNING_KEY): ` +
|
|
||||||
"value too short: must be at least 32 characters, got 5\n"
|
|
||||||
if stderr.String() != want {
|
|
||||||
t.Errorf("stderr:\n%s\nwant:\n%s", stderr, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunWritesNothingToStderrOnStartAndStop starts pixad, waits until
|
|
||||||
// it answers its health check, stops it with SIGTERM, and checks that it
|
|
||||||
// wrote nothing to stderr.
|
|
||||||
func TestRunWritesNothingToStderrOnStartAndStop(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
port := freePort(t)
|
|
||||||
cmd, stderr := pixadCommand(t,
|
|
||||||
"PORT="+strconv.Itoa(port),
|
|
||||||
"PIXA_STATE_DIR="+t.TempDir(),
|
|
||||||
"PIXA_SIGNING_KEY="+validTestSigningKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := cmd.Start()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("starting pixad: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForHealthCheck(t, cmd, port, stderr)
|
|
||||||
|
|
||||||
err = cmd.Process.Signal(syscall.SIGTERM)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("signalling pixad: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cmd.Wait()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pixad: got %v, want exit status 0; stderr:\n%s", err, stderr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stderr.Len() != 0 {
|
|
||||||
t.Errorf("stderr is not empty:\n%s", stderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// freePort returns a TCP port on the loopback address that nothing
|
|
||||||
// listens on.
|
|
||||||
func freePort(t *testing.T) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var listenConfig net.ListenConfig
|
|
||||||
|
|
||||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("finding a free port: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, ok := listener.Addr().(*net.TCPAddr)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("listener address %v is not a TCP address", listener.Addr())
|
|
||||||
}
|
|
||||||
|
|
||||||
err = listener.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("closing listener: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return addr.Port
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForHealthCheck waits up to 30 seconds for pixad, started as cmd, to
|
|
||||||
// answer its health check on port. If it does not, it stops pixad and
|
|
||||||
// fails the test with what pixad wrote to stderr.
|
|
||||||
func waitForHealthCheck(t *testing.T, cmd *exec.Cmd, port int, stderr *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
url := fmt.Sprintf("http://127.0.0.1:%d/.well-known/healthcheck.json", port)
|
|
||||||
deadline := time.Now().Add(30 * time.Second)
|
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("health check request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop pixad first: stderr is complete, and safe to read, only once
|
|
||||||
// cmd.Wait has returned.
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
_ = cmd.Wait()
|
|
||||||
|
|
||||||
t.Fatalf("pixad did not answer its health check on port %d; stderr:\n%s",
|
|
||||||
port, stderr)
|
|
||||||
}
|
|
||||||
@@ -135,38 +135,10 @@ 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 pixad works out the
|
// cache_max_bytes is recorded as not explicit, so the cache works out
|
||||||
// default when it builds the cache, and that an explicit zero is recorded
|
// the default when it opens, and that an explicit zero is recorded as
|
||||||
// as explicit, so it disables the disk cache instead.
|
// explicit, so it disables the disk cache instead.
|
||||||
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+589
-375
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,97 @@
|
|||||||
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,36 +15,8 @@ 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 validateSigningKey.
|
// minimum length requirement in validate().
|
||||||
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
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
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,8 +138,7 @@ 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
|
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
||||||
// 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
|
||||||
@@ -160,8 +159,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 _, s := range new(Config).settings() {
|
for _, name := range envVarNames() {
|
||||||
t.Setenv(s.envVar, "")
|
t.Setenv(name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := validateKnownEnvVars()
|
err := validateKnownEnvVars()
|
||||||
|
|||||||
@@ -104,24 +104,33 @@ func writeConcurrently(t *testing.T, db *sql.DB) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
var sources int
|
var hits, sources int
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM source_content`).
|
err := db.QueryRowContext(ctx, `
|
||||||
Scan(&sources)
|
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
|
||||||
|
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 sources != totalRequests {
|
if hits != totalRequests || sources != totalRequests {
|
||||||
t.Errorf("%d source_content rows, want %d", sources, totalRequests)
|
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
|
||||||
|
hits, sources, totalRequests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLikeOneRequest makes the writes one request and the eviction pass
|
// writeLikeOneRequest makes the writes one request and the eviction pass
|
||||||
// make: it stores a source, records a transformed image and deletes that
|
// make: it counts a cache hit, stores a source, records a transformed image
|
||||||
// record again.
|
// and deletes that 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, `INSERT INTO source_content
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting a cache hit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
(source_key, size_bytes) VALUES (?, 1)`, key)
|
(source_key, size_bytes) VALUES (?, 1)`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storing a source: %w", err)
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
|
|||||||
@@ -82,11 +82,7 @@ func ParseMigrationVersion(filename string) (int, error) {
|
|||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New opens the database and applies the migrations, so that the parts
|
// New creates a new Database instance.
|
||||||
// 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(),
|
||||||
@@ -95,16 +91,20 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
|
|
||||||
s.log.Info("Database instantiated")
|
s.log.Info("Database instantiated")
|
||||||
|
|
||||||
err := s.connect(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
s.log.Info("Database OnStart Hook")
|
||||||
|
|
||||||
|
return s.connect(ctx)
|
||||||
|
},
|
||||||
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,7 +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{"source_content", "negative_cache"}
|
tables := []string{"source_content", "negative_cache", "cache_stats"}
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,14 @@
|
|||||||
|
|
||||||
-- Source images, one row per source URL
|
-- Source images, one row per source URL
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
||||||
-- is the SHA-256 of the source's host, path and query.
|
-- 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 (
|
||||||
source_key TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
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
|
|
||||||
ON source_content(checked_at);
|
|
||||||
|
|
||||||
-- Processed variant blobs
|
-- Processed variant blobs
|
||||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key>. Tracked here
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key>. Tracked here
|
||||||
@@ -28,24 +18,14 @@ CREATE INDEX IF NOT EXISTS idx_source_content_checked
|
|||||||
-- without a directory scan, and so LRU eviction has a timestamp to order
|
-- without a directory scan, and so LRU eviction has a timestamp to order
|
||||||
-- on. A cache hit does not read this table: it takes the content type
|
-- on. 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
|
-- 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
|
-- later, in a batch.
|
||||||
-- 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,
|
||||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_accessed_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_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
|
||||||
@@ -128,6 +108,18 @@ CREATE TABLE IF NOT EXISTS negative_cache (
|
|||||||
);
|
);
|
||||||
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.
|
||||||
|
|||||||
+28
-21
@@ -16,6 +16,9 @@ const (
|
|||||||
DefaultQuality = 85
|
DefaultQuality = 85
|
||||||
DefaultFormat = imagerequest.FormatJXL
|
DefaultFormat = imagerequest.FormatJXL
|
||||||
DefaultFitMode = imagerequest.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.
|
||||||
@@ -45,20 +48,23 @@ type Generator struct {
|
|||||||
key [seal.KeySize]byte
|
key [seal.KeySize]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGenerator creates an encrypted URL generator that encrypts its tokens
|
// NewGenerator creates an encrypted URL generator with a key derived
|
||||||
// with key.
|
// from the signing key.
|
||||||
func NewGenerator(key [seal.KeySize]byte) *Generator {
|
func NewGenerator(signingKey string) (*Generator, error) {
|
||||||
return &Generator{key: key}
|
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates an encrypted URL token from the request's source and
|
return &Generator{key: key}, nil
|
||||||
// transform, each as the request holds it, and from expires; an empty format,
|
}
|
||||||
// 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,
|
// Generate creates an encrypted URL token from the request's source,
|
||||||
|
// transform and expiry, each as the request holds it; an empty format, 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.
|
// encrypted with NaCl secretbox, and base64url-encoded.
|
||||||
func (g *Generator) Generate(
|
func (g *Generator) Generate(req *imagerequest.Request) (string, error) {
|
||||||
req *imagerequest.Request, expires time.Time,
|
|
||||||
) (string, error) {
|
|
||||||
p := &payload{
|
p := &payload{
|
||||||
SourceHost: req.Source.Host,
|
SourceHost: req.Source.Host,
|
||||||
SourcePath: req.Source.Path,
|
SourcePath: req.Source.Path,
|
||||||
@@ -70,8 +76,8 @@ func (g *Generator) Generate(
|
|||||||
FitMode: req.Transform.FitMode,
|
FitMode: req.Transform.FitMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !expires.IsZero() {
|
if !req.Expires.IsZero() {
|
||||||
p.ExpiresAt = expires.Unix()
|
p.ExpiresAt = req.Expires.Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CBOR encode the payload
|
// CBOR encode the payload
|
||||||
@@ -85,19 +91,19 @@ func (g *Generator) Generate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse decrypts and validates an encrypted URL token and returns the
|
// Parse decrypts and validates an encrypted URL token and returns the
|
||||||
// request it holds, with default values for omitted optional fields, and its
|
// request it holds, with default values for omitted optional fields. A token
|
||||||
// expiry. A token that never expires gives the zero time.
|
// that never expires gives the zero Expires.
|
||||||
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
|
||||||
func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, error) {
|
func (g *Generator) Parse(token string) (*imagerequest.Request, error) {
|
||||||
// Decrypt
|
// Decrypt
|
||||||
data, err := seal.Decrypt(g.key, token)
|
data, err := seal.Decrypt(g.key, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
||||||
errors.Is(err, seal.ErrInvalidPayload) {
|
errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
return nil, time.Time{}, ErrDecryptFailed
|
return nil, ErrDecryptFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, time.Time{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
@@ -105,12 +111,12 @@ func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, error
|
|||||||
|
|
||||||
err = cbor.Unmarshal(data, &p)
|
err = cbor.Unmarshal(data, &p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, ErrInvalidFormat
|
return nil, 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, time.Time{}, ErrExpired
|
return nil, ErrExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
format := p.Format
|
format := p.Format
|
||||||
@@ -145,5 +151,6 @@ func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, error
|
|||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
},
|
},
|
||||||
}, expires, nil
|
Expires: expires,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared test fixture strings.
|
// Shared test fixture strings.
|
||||||
@@ -20,13 +19,11 @@ const (
|
|||||||
func TestGenerator_GenerateAndParse(t *testing.T) {
|
func TestGenerator_GenerateAndParse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys("test-signing-key-12345")
|
gen, err := encurl.NewGenerator("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: testSourceHost,
|
Host: testSourceHost,
|
||||||
@@ -39,10 +36,10 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
Quality: 90,
|
Quality: 90,
|
||||||
FitMode: imagerequest.FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
},
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
expires := time.Now().Add(time.Hour)
|
|
||||||
|
|
||||||
token, err := gen.Generate(req, expires)
|
token, err := gen.Generate(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -51,7 +48,7 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
t.Fatal("Generate() returned empty token")
|
t.Fatal("Generate() returned empty token")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, parsedExpires, err := gen.Parse(token)
|
parsed, err := gen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse() error = %v", err)
|
t.Fatalf("Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -94,30 +91,30 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
parsed.Transform.FitMode, req.Transform.FitMode)
|
parsed.Transform.FitMode, req.Transform.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsedExpires.Unix() != expires.Unix() {
|
if parsed.Expires.Unix() != req.Expires.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsedExpires.Unix(), expires.Unix())
|
t.Errorf("ExpiresAt = %d, want %d", parsed.Expires.Unix(), req.Expires.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_Expired(t *testing.T) {
|
func TestGenerator_Parse_Expired(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: testSourceHost,
|
Host: testSourceHost,
|
||||||
Path: testSourcePath,
|
Path: testSourcePath,
|
||||||
},
|
},
|
||||||
|
Expires: time.Now().Add(-time.Hour), // Already expired
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := gen.Generate(req, time.Now().Add(-time.Hour)) // Already expired
|
token, err := gen.Generate(req)
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -130,10 +127,9 @@ 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()
|
||||||
|
|
||||||
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("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")
|
||||||
}
|
}
|
||||||
@@ -142,17 +138,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()
|
||||||
|
|
||||||
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: testSourceHost,
|
Host: testSourceHost,
|
||||||
Path: testSourcePath,
|
Path: testSourcePath,
|
||||||
},
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
|
token, _ := gen.Generate(req)
|
||||||
|
|
||||||
// Tamper with the token
|
// Tamper with the token
|
||||||
tampered := []byte(token)
|
tampered := []byte(token)
|
||||||
@@ -160,7 +156,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")
|
||||||
}
|
}
|
||||||
@@ -169,21 +165,20 @@ 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()
|
||||||
|
|
||||||
keys1, _ := seal.DeriveKeys("signing-key-1")
|
gen1, _ := encurl.NewGenerator("signing-key-1")
|
||||||
keys2, _ := seal.DeriveKeys("signing-key-2")
|
gen2, _ := encurl.NewGenerator("signing-key-2")
|
||||||
gen1 := encurl.NewGenerator(keys1.EncryptedURLKey)
|
|
||||||
gen2 := encurl.NewGenerator(keys2.EncryptedURLKey)
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: testSourceHost,
|
Host: testSourceHost,
|
||||||
Path: testSourcePath,
|
Path: testSourcePath,
|
||||||
},
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen1.Generate(req, time.Now().Add(time.Hour))
|
token, _ := gen1.Generate(req)
|
||||||
|
|
||||||
_, _, 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")
|
||||||
}
|
}
|
||||||
@@ -192,8 +187,7 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
|
|||||||
func TestGenerator_Parse_Defaults(t *testing.T) {
|
func TestGenerator_Parse_Defaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
||||||
|
|
||||||
// Request with only required fields - should get defaults
|
// Request with only required fields - should get defaults
|
||||||
token, err := gen.Generate(&imagerequest.Request{
|
token, err := gen.Generate(&imagerequest.Request{
|
||||||
@@ -201,12 +195,13 @@ func TestGenerator_Parse_Defaults(t *testing.T) {
|
|||||||
Host: testSourceHost,
|
Host: testSourceHost,
|
||||||
Path: testSourcePath,
|
Path: testSourcePath,
|
||||||
},
|
},
|
||||||
}, time.Now().Add(time.Hour))
|
Expires: time.Now().Add(time.Hour),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, _, err := gen.Parse(token)
|
req, err := gen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse() error = %v", err)
|
t.Fatalf("Parse() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -230,17 +225,17 @@ func TestGenerator_Parse_Defaults(t *testing.T) {
|
|||||||
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
keys, _ := seal.DeriveKeys("test-signing-key-12345")
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
gen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: testSourceHost,
|
Host: testSourceHost,
|
||||||
Path: testSourcePath,
|
Path: testSourcePath,
|
||||||
},
|
},
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _ := gen.Generate(req, time.Now().Add(time.Hour))
|
token, _ := gen.Generate(req)
|
||||||
|
|
||||||
// Check that token only contains URL-safe characters
|
// Check that token only contains URL-safe characters
|
||||||
for _, c := range token {
|
for _, c := range token {
|
||||||
|
|||||||
+17
-14
@@ -153,20 +153,20 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The image URL must fetch the source URL as it was written. The
|
// The image URL must fetch the source URL as it was written
|
||||||
// fetcher fetches over http only when allow_http is set.
|
req.AllowHTTP = s.config.AllowHTTP
|
||||||
fetchURL := req.Source.URL(s.config.AllowHTTP)
|
fetchURL := req.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.String(),
|
"written: pixa would fetch "+fetchURL,
|
||||||
r.Form, http.StatusBadRequest)
|
r.Form, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(req, expiresAt)
|
token, err := s.encGen.Generate(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
@@ -226,23 +226,25 @@ 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, fetchURL *url.URL) bool {
|
func fetchedAsWritten(source *url.URL, fetchURL string) bool {
|
||||||
writtenPath := source.RawPath
|
writtenPath := source.RawPath
|
||||||
if writtenPath == "" {
|
if writtenPath == "" {
|
||||||
writtenPath = source.EscapedPath()
|
writtenPath = source.EscapedPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.User == nil && !source.ForceQuery &&
|
fetched, err := url.Parse(fetchURL)
|
||||||
fetchURL.EscapedPath() == writtenPath &&
|
|
||||||
fetchURL.RawQuery == source.RawQuery
|
return err == nil && source.User == nil && !source.ForceQuery &&
|
||||||
|
fetched.EscapedPath() == writtenPath &&
|
||||||
|
fetched.RawQuery == source.RawQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildGenerateRequest parses the numeric form fields and assembles the
|
// buildGenerateRequest parses the numeric form fields and assembles the
|
||||||
// image request the encrypted URL's token is made from, and its expiry.
|
// image request the encrypted URL's token is made from. ttl=0 means never
|
||||||
// ttl=0 means never expires (the expiry is the zero time). A non-numeric or
|
// expires (Expires stays the zero time). A non-numeric or out-of-range
|
||||||
// out-of-range width, height, quality or ttl, or an unrecognized fit mode, is
|
// width, height, quality or ttl, or an unrecognized fit mode, is a client
|
||||||
// a client error naming the offending field. The format field is passed
|
// error naming the offending field. The format field is passed through
|
||||||
// through unchecked.
|
// unchecked.
|
||||||
func buildGenerateRequest(
|
func buildGenerateRequest(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*imagerequest.Request, time.Time, int, error) {
|
) (*imagerequest.Request, time.Time, int, error) {
|
||||||
@@ -292,6 +294,7 @@ func buildGenerateRequest(
|
|||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
},
|
},
|
||||||
|
Expires: expiresAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, expiresAt, ttl, nil
|
return req, expiresAt, ttl, nil
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,17 +44,27 @@ func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
|||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
sessMgr: sessMgr,
|
||||||
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
encGen: encGen,
|
||||||
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug, slog.New(slog.DiscardHandler)),
|
csrfProtect: protect,
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,15 +22,15 @@ func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("session.NewManager() 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: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
sessMgr: sessMgr,
|
||||||
}
|
}
|
||||||
|
|
||||||
submittedKeys := []string{wrongKey, testSigningKey}
|
submittedKeys := []string{wrongKey, testSigningKey}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,13 +232,8 @@ 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(), keys.SessionHashKey, keys.SessionBlockKey, db)
|
t.Context(), testSigningKey, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError, which
|
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and
|
||||||
// answers the errors of both image routes, answers 400 naming the AVIF pixel
|
// handleImageError, which answer the errors of the two image routes, answer
|
||||||
// limit when the image processor refuses an AVIF output above it, wrapped as
|
// 400 naming the AVIF pixel limit when the image processor refuses an AVIF
|
||||||
// the service wraps it. The request's source only goes into a log line, so it
|
// output above it, wrapped as the service wraps it. The request's source only
|
||||||
// is left empty.
|
// goes into a log line, so it is left empty.
|
||||||
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -25,15 +25,25 @@ 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)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
plain := httptest.NewRecorder()
|
||||||
h.respondImageError(rec, &imagerequest.Request{}, err)
|
h.respondImageError(plain, &imagerequest.Request{}, err)
|
||||||
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"respondImageError": plain, "handleImageError": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
t.Errorf("%s: status = %d, want %d",
|
||||||
|
name, rec.Code, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), wantBody) {
|
if !strings.Contains(rec.Body.String(), wantBody) {
|
||||||
t.Errorf("body = %q, want it to say %q", rec.Body.String(), wantBody)
|
t.Errorf("%s: body = %q, want it to say %q",
|
||||||
|
name, rec.Body.String(), wantBody)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,17 +32,27 @@ func newBodyLimitTestRouter(
|
|||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
sessMgr, err := session.NewManager(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("session.NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
sessMgr: sessMgr,
|
||||||
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
encGen: encGen,
|
||||||
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug, slog.New(slog.DiscardHandler)),
|
csrfProtect: protect,
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -17,17 +17,11 @@ 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/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/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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// countingHost is the allowlisted host whose photoPath the server of
|
// countingHost is the allowlisted host whose photoPath the server of
|
||||||
@@ -107,8 +101,9 @@ func newCountingDatabase(tb testing.TB) (*sql.DB, *atomic.Int64) {
|
|||||||
// newCountingDatabaseServer returns a router for both image routes whose
|
// newCountingDatabaseServer returns a router for both image routes whose
|
||||||
// image cache uses newCountingDatabase, the count of the statements run on
|
// image cache uses newCountingDatabase, the count of the statements run on
|
||||||
// that database, and a URL on each route for a different image of
|
// 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
|
// photoPath on countingHost. Nothing writes the cache's pending counts and
|
||||||
// background, so every statement counted comes from a request.
|
// access times in the background, so every statement counted comes from a
|
||||||
|
// request.
|
||||||
func newCountingDatabaseServer(
|
func newCountingDatabaseServer(
|
||||||
tb testing.TB,
|
tb testing.TB,
|
||||||
) (http.Handler, *atomic.Int64, []string) {
|
) (http.Handler, *atomic.Int64, []string) {
|
||||||
@@ -123,38 +118,32 @@ func newCountingDatabaseServer(
|
|||||||
tb.Fatalf("encoding the photo: %v", err)
|
tb.Fatalf("encoding the photo: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(db, imgcache.Config{
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
StateDir: tb.TempDir(),
|
StateDir: tb.TempDir(),
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
MaxBytes: 1 << 30,
|
})
|
||||||
}, prometheus.NewRegistry())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := imageproxy.New(&imageproxy.Config{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(fstest.MapFS{
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
|
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
|
||||||
}),
|
}),
|
||||||
Processor: imageprocessor.New(imageprocessor.Params{
|
SigningKey: testSigningKey,
|
||||||
MaxInputBytes: 50 << 20,
|
Allowlist: []string{countingHost},
|
||||||
MaxConcurrentProcessing: 2,
|
|
||||||
}),
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
Logger: slog.New(slog.DiscardHandler),
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
}, prometheus.NewRegistry())
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tb.Fatalf("imageproxy.New() error = %v", err)
|
tb.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tb.Fatalf("seal.DeriveKeys() error = %v", err)
|
tb.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
||||||
|
|
||||||
token, err := encGen.Generate(&imagerequest.Request{
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
|
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
|
||||||
Transform: imagerequest.Transform{
|
Transform: imagerequest.Transform{
|
||||||
@@ -163,7 +152,7 @@ func newCountingDatabaseServer(
|
|||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}, time.Time{})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tb.Fatalf("encGen.Generate() error = %v", err)
|
tb.Fatalf("encGen.Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,8 +161,6 @@ func newCountingDatabaseServer(
|
|||||||
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{countingHost}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -229,9 +216,9 @@ func TestImageRoutes_CacheHitsMakeNoDatabaseCall(t *testing.T) {
|
|||||||
rec := serveImage(t, srv, target)
|
rec := serveImage(t, srv, target)
|
||||||
|
|
||||||
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
if cacheStatus != string(imageproxy.CacheHit) {
|
if cacheStatus != string(imgcache.CacheHit) {
|
||||||
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
target, cacheStatus, imageproxy.CacheHit)
|
target, cacheStatus, imgcache.CacheHit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,9 +248,9 @@ func BenchmarkHandleImage_CacheHits(b *testing.B) {
|
|||||||
rec := serveImage(b, srv, target)
|
rec := serveImage(b, srv, target)
|
||||||
|
|
||||||
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
if cacheStatus != string(imageproxy.CacheHit) {
|
if cacheStatus != string(imgcache.CacheHit) {
|
||||||
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
target, cacheStatus, imageproxy.CacheHit)
|
target, cacheStatus, imgcache.CacheHit)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
|
||||||
|
// built from cache_max_bytes: omitted, the cache works out the default
|
||||||
|
// limit; 0 turns the disk cache off; a positive value is the limit,
|
||||||
|
// unchanged.
|
||||||
|
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const oneGiB = 1 << 30
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cacheMaxBytes int64
|
||||||
|
cacheMaxBytesExplicit bool
|
||||||
|
wantMaxBytes int64
|
||||||
|
wantUseDefaultMaxBytes bool
|
||||||
|
wantDisableDiskCache bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cache_max_bytes omitted",
|
||||||
|
cacheMaxBytes: 0,
|
||||||
|
cacheMaxBytesExplicit: false,
|
||||||
|
wantMaxBytes: 0,
|
||||||
|
wantUseDefaultMaxBytes: true,
|
||||||
|
wantDisableDiskCache: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cache_max_bytes: 0",
|
||||||
|
cacheMaxBytes: 0,
|
||||||
|
cacheMaxBytesExplicit: true,
|
||||||
|
wantMaxBytes: 0,
|
||||||
|
wantUseDefaultMaxBytes: false,
|
||||||
|
wantDisableDiskCache: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cache_max_bytes: 1 GiB",
|
||||||
|
cacheMaxBytes: oneGiB,
|
||||||
|
cacheMaxBytesExplicit: true,
|
||||||
|
wantMaxBytes: oneGiB,
|
||||||
|
wantUseDefaultMaxBytes: false,
|
||||||
|
wantDisableDiskCache: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
CacheMaxBytes: tc.cacheMaxBytes,
|
||||||
|
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := newCacheConfig(cfg, nil)
|
||||||
|
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
|
||||||
|
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
|
||||||
|
|
||||||
|
if got.MaxBytes != tc.wantMaxBytes {
|
||||||
|
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
|
||||||
|
t.Errorf("UseDefaultMaxBytes = %v, want %v",
|
||||||
|
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.DisableDiskCache != tc.wantDisableDiskCache {
|
||||||
|
t.Errorf("DisableDiskCache = %v, want %v",
|
||||||
|
got.DisableDiskCache, tc.wantDisableDiskCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
|
||||||
|
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
|
||||||
|
// and checks by whether the cache directories were created that the
|
||||||
|
// disk cache is on in the first case and off in the second.
|
||||||
|
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cacheMaxBytesExplicit bool
|
||||||
|
wantDiskCache bool
|
||||||
|
}{
|
||||||
|
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
|
||||||
|
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
StateDir: stateDir,
|
||||||
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
|
CacheMaxBytes: 0,
|
||||||
|
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("database.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
t.Cleanup(lc.RequireStop)
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
|
||||||
|
gotDiskCache := err == nil
|
||||||
|
|
||||||
|
if gotDiskCache != tc.wantDiskCache {
|
||||||
|
t.Errorf("cache directories created = %v, want %v",
|
||||||
|
gotDiskCache, tc.wantDiskCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-108
@@ -1,43 +1,23 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The causes the form-refused page names, each with what to do about it.
|
// csrfKeySalt provides domain separation for the CSRF authentication key,
|
||||||
const (
|
// derived from the signing key so tokens survive restarts without extra
|
||||||
causePlainHTTP = "This page was opened over plain HTTP while debug is " +
|
// configuration and never reuse the session or encrypted-URL key material.
|
||||||
"off. Open it over HTTPS, or turn debug on to use plain HTTP on " +
|
const csrfKeySalt = "pixa-csrf-v1"
|
||||||
"your own machine."
|
|
||||||
causeHTTPSWithDebug = "This page was opened over HTTPS while debug is " +
|
|
||||||
"on. Turn debug off, or open the page over plain HTTP."
|
|
||||||
causeOtherHost = "The form was sent from another host than the one in " +
|
|
||||||
"the request's Host header. A reverse proxy in front of pixa must " +
|
|
||||||
"pass the Host header on unchanged."
|
|
||||||
causeNoReferer = "The browser sent neither an Origin nor a Referer " +
|
|
||||||
"header, and over HTTPS pixa needs one to check where the form " +
|
|
||||||
"came from. Let the browser send the Referer header to this site."
|
|
||||||
causeStaleToken = "The form token is missing or out of date. Reload " +
|
|
||||||
"the page and submit the form again."
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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). key signs that cookie. A
|
// where no session exists yet (login CSRF).
|
||||||
// refused form is answered by the handler that handleFormRefused returns.
|
|
||||||
// log gets a warning with the library's reason, such as "origin invalid".
|
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -45,18 +25,22 @@ const (
|
|||||||
// 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(
|
||||||
key [seal.KeySize]byte, plaintext bool, log *slog.Logger,
|
signingKey string, plaintext bool,
|
||||||
) func(http.Handler) http.Handler {
|
) (func(http.Handler) http.Handler, error) {
|
||||||
|
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("/"),
|
||||||
csrf.Secure(!plaintext),
|
csrf.Secure(!plaintext),
|
||||||
csrf.SameSite(csrf.SameSiteStrictMode),
|
csrf.SameSite(csrf.SameSiteStrictMode),
|
||||||
csrf.ErrorHandler(handleFormRefused(plaintext, log)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if !plaintext {
|
if !plaintext {
|
||||||
return protect
|
return protect, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
@@ -65,85 +49,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
|
||||||
}
|
|
||||||
|
|
||||||
// handleFormRefused returns the handler the CSRF middleware calls when it
|
|
||||||
// refuses a request. It logs the library's reason with the request ID and
|
|
||||||
// answers 403 with a page naming the cause and what to do. A reason that is
|
|
||||||
// not a refusal, such as a failure to make a token, answers 500.
|
|
||||||
func handleFormRefused(plaintext bool, log *slog.Logger) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
reason := csrf.FailureReason(r)
|
|
||||||
requestID := middleware.GetReqID(r.Context())
|
|
||||||
|
|
||||||
cause := formRefusedCause(r, reason, plaintext)
|
|
||||||
if cause == "" {
|
|
||||||
log.Error("CSRF middleware failed",
|
|
||||||
"request_id", requestID, "error", reason)
|
|
||||||
http.Error(w, "Internal server error",
|
|
||||||
http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Warn("form refused", "request_id", requestID, "reason", reason)
|
|
||||||
|
|
||||||
var page bytes.Buffer
|
|
||||||
|
|
||||||
err := templates.Render(&page, "form_refused.html",
|
|
||||||
struct{ Cause string }{Cause: cause})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to render form refused template", "error", err)
|
|
||||||
http.Error(w, "Internal server error",
|
|
||||||
http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
|
|
||||||
_, err = page.WriteTo(w)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to write form refused page", "error", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// formRefusedCause returns the cause the form-refused page names for the
|
|
||||||
// CSRF middleware's reason, or "" when the reason is not a refusal.
|
|
||||||
func formRefusedCause(r *http.Request, reason error, plaintext bool) string {
|
|
||||||
switch {
|
|
||||||
case errors.Is(reason, csrf.ErrNoToken), errors.Is(reason, csrf.ErrBadToken):
|
|
||||||
return causeStaleToken
|
|
||||||
case errors.Is(reason, csrf.ErrNoReferer):
|
|
||||||
return causeNoReferer
|
|
||||||
case errors.Is(reason, csrf.ErrBadOrigin):
|
|
||||||
return pageAddressCause(r.Header.Get("Origin"), r.Host, plaintext)
|
|
||||||
case errors.Is(reason, csrf.ErrBadReferer):
|
|
||||||
return pageAddressCause(r.Referer(), r.Host, plaintext)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageAddressCause returns the cause for a form whose page address, the
|
|
||||||
// Origin or Referer header, the CSRF middleware refused. The middleware
|
|
||||||
// expects the request's own host, with https when plaintext is false and
|
|
||||||
// http when it is true. So on the request's own host, only the scheme was
|
|
||||||
// wrong.
|
|
||||||
func pageAddressCause(pageAddress, host string, plaintext bool) string {
|
|
||||||
page, err := url.Parse(pageAddress)
|
|
||||||
if err != nil || page.Host != host {
|
|
||||||
return causeOtherHost
|
|
||||||
}
|
|
||||||
|
|
||||||
if plaintext {
|
|
||||||
return causeHTTPSWithDebug
|
|
||||||
}
|
|
||||||
|
|
||||||
return causePlainHTTP
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF returns the CSRF-protection middleware for the login and generator
|
// CSRF returns the CSRF-protection middleware for the login and generator
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"html/template"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/gorilla/csrf"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// refusedFormRequestID is the request ID of the requests postRefusedForm
|
|
||||||
// sends, which the warning for a refused form must carry.
|
|
||||||
const refusedFormRequestID = "test-request-id"
|
|
||||||
|
|
||||||
// TestFormRefusedNamesCause verifies that a form the CSRF middleware refuses
|
|
||||||
// answers 403 with a page naming the cause, and logs a warning with the
|
|
||||||
// request ID and the middleware's reason.
|
|
||||||
func TestFormRefusedNamesCause(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
debug bool
|
|
||||||
origin string
|
|
||||||
referer string
|
|
||||||
wantCause string
|
|
||||||
wantReason error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "plain HTTP origin with debug off",
|
|
||||||
origin: "http://example.com",
|
|
||||||
wantCause: causePlainHTTP,
|
|
||||||
wantReason: csrf.ErrBadOrigin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain HTTP referer with debug off",
|
|
||||||
referer: "http://example.com/",
|
|
||||||
wantCause: causePlainHTTP,
|
|
||||||
wantReason: csrf.ErrBadReferer,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "origin on another host",
|
|
||||||
origin: "https://proxy.example.net",
|
|
||||||
wantCause: causeOtherHost,
|
|
||||||
wantReason: csrf.ErrBadOrigin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "referer on another host",
|
|
||||||
referer: "https://proxy.example.net/",
|
|
||||||
wantCause: causeOtherHost,
|
|
||||||
wantReason: csrf.ErrBadReferer,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTPS origin with debug on",
|
|
||||||
debug: true,
|
|
||||||
origin: "https://example.com",
|
|
||||||
wantCause: causeHTTPSWithDebug,
|
|
||||||
wantReason: csrf.ErrBadOrigin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no origin or referer with debug off",
|
|
||||||
wantCause: causeNoReferer,
|
|
||||||
wantReason: csrf.ErrNoReferer,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing token",
|
|
||||||
debug: true,
|
|
||||||
origin: "http://example.com",
|
|
||||||
wantCause: causeStaleToken,
|
|
||||||
wantReason: csrf.ErrNoToken,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rec, logLine := postRefusedForm(t, tt.debug, tt.origin, tt.referer,
|
|
||||||
nil, "")
|
|
||||||
checkFormRefused(t, rec, logLine, tt.wantCause, tt.wantReason)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFormRefusedNamesCauseForForeignToken verifies that a form whose token
|
|
||||||
// does not match its CSRF cookie answers 403 with a page naming
|
|
||||||
// causeStaleToken, and logs a warning with the request ID and the
|
|
||||||
// middleware's reason.
|
|
||||||
func TestFormRefusedNamesCauseForForeignToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The router and postRefusedForm derive their CSRF key from
|
|
||||||
// testSigningKey, so cookiesA is valid there and only tokenB is wrong.
|
|
||||||
_, router := newCSRFTestRouter(t)
|
|
||||||
cookiesA, _ := csrfCredentials(t, router, nil)
|
|
||||||
_, tokenB := csrfCredentials(t, router, nil)
|
|
||||||
|
|
||||||
rec, logLine := postRefusedForm(t, true, "http://example.com", "",
|
|
||||||
cookiesA, tokenB)
|
|
||||||
checkFormRefused(t, rec, logLine, causeStaleToken, csrf.ErrBadToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// postRefusedForm sends a POST with the given cookies to the CSRF
|
|
||||||
// middleware built for debug. It adds the form token and the Origin and
|
|
||||||
// Referer headers when they are not empty. It returns the response and the
|
|
||||||
// one JSON log line the middleware wrote.
|
|
||||||
func postRefusedForm(
|
|
||||||
t *testing.T, debug bool, origin, referer string,
|
|
||||||
cookies []*http.Cookie, token string,
|
|
||||||
) (*httptest.ResponseRecorder, map[string]any) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var logs bytes.Buffer
|
|
||||||
|
|
||||||
log := slog.New(slog.NewJSONHandler(&logs, nil))
|
|
||||||
reached := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
||||||
t.Error("the refused form reached the handler behind the middleware")
|
|
||||||
})
|
|
||||||
srv := newCSRFProtect(keys.CSRFKey, debug, log)(reached)
|
|
||||||
|
|
||||||
form := url.Values{loginKeyField: {"x"}}
|
|
||||||
if token != "" {
|
|
||||||
form.Set(csrfTokenField, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(),
|
|
||||||
middleware.RequestIDKey, refusedFormRequestID)
|
|
||||||
req := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
|
||||||
"http://example.com/", strings.NewReader(form.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
for _, c := range cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if origin != "" {
|
|
||||||
req.Header.Set("Origin", origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if referer != "" {
|
|
||||||
req.Header.Set("Referer", referer)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
var logLine map[string]any
|
|
||||||
|
|
||||||
err = json.Unmarshal(logs.Bytes(), &logLine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding log line %q: %v", logs.String(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rec, logLine
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkFormRefused checks that rec is a 403 page naming wantCause and that
|
|
||||||
// logLine is a warning carrying the request ID and wantReason.
|
|
||||||
func checkFormRefused(
|
|
||||||
t *testing.T, rec *httptest.ResponseRecorder, logLine map[string]any,
|
|
||||||
wantCause string, wantReason error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := rec.Body.String()
|
|
||||||
t.Logf("body: %s", body)
|
|
||||||
|
|
||||||
if !strings.Contains(body, template.HTMLEscapeString(wantCause)) {
|
|
||||||
t.Errorf("page does not name the cause %q", wantCause)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("log line: %v", logLine)
|
|
||||||
|
|
||||||
if logLine["level"] != "WARN" || logLine["msg"] != "form refused" {
|
|
||||||
t.Errorf("log line = %v, want a WARN \"form refused\"", logLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
if logLine["request_id"] != refusedFormRequestID {
|
|
||||||
t.Errorf("logged request_id = %v, want %q",
|
|
||||||
logLine["request_id"], refusedFormRequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if logLine["reason"] != wantReason.Error() {
|
|
||||||
t.Errorf("logged reason = %v, want %q", logLine["reason"], wantReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided builds the handlers as
|
||||||
|
// pixad does, in an fx app that provides no fetcher, and requests an image
|
||||||
|
// from 192.0.2.10, which is on the allowlist and in blocked_networks. The URL
|
||||||
|
// check accepts that address; only the dialer that refuses internal
|
||||||
|
// addresses checks blocked_networks, so the answer is 403 only if the
|
||||||
|
// fetcher the handlers build from the config connects with that dialer. Any
|
||||||
|
// other dialer would try to connect until the upstream fetch timeout, which
|
||||||
|
// is short so that the test then fails quickly.
|
||||||
|
func TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "192.0.2.10"
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
StateDir: stateDir,
|
||||||
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||||
|
AllowlistHosts: []string{host},
|
||||||
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
|
UpstreamFetchTimeout: 2 * time.Second,
|
||||||
|
// With no connection slots, the fetch would fail before dialing.
|
||||||
|
UpstreamConnections: config.DefaultUpstreamConnections,
|
||||||
|
}
|
||||||
|
|
||||||
|
var h *Handlers
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Supply(cfg),
|
||||||
|
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New),
|
||||||
|
fx.Populate(&h),
|
||||||
|
)
|
||||||
|
app.RequireStart()
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
|
||||||
|
rec := sendGet(t, r, photoURL(host))
|
||||||
|
checkErrorBody(t, rec, http.StatusForbidden, "forbidden")
|
||||||
|
}
|
||||||
@@ -12,16 +12,10 @@ 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/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
|
||||||
@@ -31,51 +25,38 @@ 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 which have
|
// them, whose image service fetches through fetcher and has allowlistedHost on
|
||||||
// allowlistedHost on their allowlist. The handlers and the image service log
|
// its allowlist. The handlers and the image service log to 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.Config{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
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 := imageproxy.New(&imageproxy.Config{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
Processor: imageprocessor.New(imageprocessor.Params{
|
SigningKey: testSigningKey,
|
||||||
MaxInputBytes: 50 << 20,
|
Allowlist: []string{allowlistedHost},
|
||||||
MaxConcurrentProcessing: 2,
|
|
||||||
}),
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
Logger: log,
|
Logger: log,
|
||||||
}, prometheus.NewRegistry())
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imageproxy.New() error = %v", err)
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
h := &Handlers{log: log, imgSvc: svc, encGen: encGen}
|
||||||
|
|
||||||
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())
|
||||||
@@ -104,7 +85,7 @@ func mismatchedSourceEncURL(t *testing.T, h *Handlers) string {
|
|||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
},
|
},
|
||||||
}, time.Time{})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,17 +18,16 @@ var (
|
|||||||
"image/jxl, image/avif, image/webp or image/jpeg")
|
"image/jxl, image/avif, image/webp or image/jpeg")
|
||||||
)
|
)
|
||||||
|
|
||||||
// chooseAutoFormat returns the format to serve for the requested format: for
|
// chooseAutoFormat replaces the format auto in req with the format
|
||||||
// the format auto, the format formatForAccept chooses from r's Accept header,
|
// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the
|
||||||
// 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 the image can be served.
|
// and reports whether req can be served. Any other format is left as it is.
|
||||||
func (s *Handlers) chooseAutoFormat(
|
func (s *Handlers) chooseAutoFormat(
|
||||||
w http.ResponseWriter, r *http.Request, requested imagerequest.Format,
|
w http.ResponseWriter, r *http.Request, req *imagerequest.Request,
|
||||||
) (imagerequest.Format, bool) {
|
) bool {
|
||||||
if requested != imagerequest.FormatAuto {
|
if req.Transform.Format != imagerequest.FormatAuto {
|
||||||
return requested, true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Vary", "Accept")
|
w.Header().Add("Vary", "Accept")
|
||||||
@@ -37,16 +36,18 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
return format, true
|
req.Transform.Format = format
|
||||||
|
|
||||||
|
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
|
||||||
@@ -65,20 +66,20 @@ func formatForAccept(accept string) (imagerequest.Format, error) {
|
|||||||
return imagerequest.FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities[imagerequest.FormatJXL.MIMEType()] > 0 {
|
if qualities["image/jxl"] > 0 {
|
||||||
return imagerequest.FormatJXL, nil
|
return imagerequest.FormatJXL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities[imagerequest.FormatAVIF.MIMEType()] > 0 {
|
if qualities["image/avif"] > 0 {
|
||||||
return imagerequest.FormatAVIF, nil
|
return imagerequest.FormatAVIF, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities[imagerequest.FormatWebP.MIMEType()] > 0 {
|
if qualities["image/webp"] > 0 {
|
||||||
return imagerequest.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[imagerequest.FormatJPEG.MIMEType()]
|
quality, named := qualities["image/jpeg"]
|
||||||
if !named {
|
if !named {
|
||||||
quality, named = qualities["image/*"]
|
quality, named = qualities["image/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,13 +75,11 @@ 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. The first is signed with testSigningKey, the key the
|
// the format auto, made with h's image service and generator.
|
||||||
// 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 := generateSignedURL(testSigningKey, &imagerequest.Request{
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: photoPath,
|
Path: photoPath,
|
||||||
@@ -91,6 +89,9 @@ func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
|||||||
Format: imagerequest.FormatAuto,
|
Format: imagerequest.FormatAuto,
|
||||||
},
|
},
|
||||||
}, time.Minute)
|
}, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&imagerequest.Request{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
@@ -101,7 +102,8 @@ func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
|||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: imagerequest.FormatAuto,
|
Format: imagerequest.FormatAuto,
|
||||||
},
|
},
|
||||||
}, time.Now().Add(time.Minute))
|
Expires: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -186,9 +188,9 @@ func TestFormatAuto_ChosenFromAccept(t *testing.T) {
|
|||||||
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedForAVIF := generateSignedURL(testSigningKey, &imagerequest.Request{
|
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: photoPath,
|
Path: photoPath,
|
||||||
@@ -198,6 +200,9 @@ func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
|
|||||||
Format: imagerequest.FormatAVIF,
|
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 {
|
||||||
|
|||||||
+133
-23
@@ -4,6 +4,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,13 +12,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/imageproxy"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"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.
|
||||||
@@ -26,50 +27,70 @@ 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
|
|
||||||
|
|
||||||
ImageService *imageproxy.Proxy
|
// Fetcher, when provided, fetches upstream images in place of the
|
||||||
SessionManager *session.Manager
|
// fetcher the handlers build from the config. Only tests provide one;
|
||||||
EncryptedURLGenerator *encurl.Generator
|
// pixad does not.
|
||||||
|
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
|
||||||
imgSvc *imageproxy.Proxy
|
fetcher httpfetcher.Fetcher
|
||||||
|
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(_ fx.Lifecycle, params Params) *Handlers {
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
log := params.Logger.Get()
|
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Handlers{
|
s := &Handlers{
|
||||||
log: log,
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
imgSvc: params.ImageService,
|
fetcher: params.Fetcher,
|
||||||
sessMgr: params.SessionManager,
|
csrfProtect: csrfProtect,
|
||||||
encGen: params.EncryptedURLGenerator,
|
|
||||||
csrfProtect: newCSRFProtect(params.Keys.CSRFKey, params.Config.Debug, log),
|
|
||||||
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 and access times 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
|
||||||
@@ -78,6 +99,95 @@ 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 and the access times of cache hits that requests
|
||||||
|
// hand over, so that no request waits for the database to record them
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
|
||||||
|
// Create the fetcher config
|
||||||
|
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,20 +18,15 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,37 +45,32 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(db, imgcache.Config{
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
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 := imageproxy.New(&imageproxy.Config{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
Processor: imageprocessor.New(imageprocessor.Params{
|
SigningKey: "test-signing-key-must-be-32-chars",
|
||||||
MaxInputBytes: 50 << 20,
|
Allowlist: []string{goodHost},
|
||||||
MaxConcurrentProcessing: 2,
|
})
|
||||||
}),
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
}, prometheus.NewRegistry())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create proxy: %v", err)
|
t.Fatalf("failed to create service: %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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,9 +121,10 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(
|
func (f *mockFetcher) Fetch(
|
||||||
_ context.Context, source imagerequest.Source, _, _ string,
|
_ context.Context, url string,
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
path := source.Host + source.Path
|
// Remove https:// prefix
|
||||||
|
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 {
|
||||||
@@ -257,90 +248,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
+97
-202
@@ -1,31 +1,23 @@
|
|||||||
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/imageproxy"
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"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>
|
||||||
@@ -35,13 +27,13 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req, sig, expires, ok := s.parseImageRequest(w, r)
|
req, ok := s.parseImageRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate signature if required
|
// Validate signature if required
|
||||||
err := s.validateSignature(req, sig, expires)
|
err := s.imgSvc.ValidateRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("signature validation failed",
|
s.log.Warn("signature validation failed",
|
||||||
"host", req.Source.Host,
|
"host", req.Source.Host,
|
||||||
@@ -53,7 +45,27 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.serveImage(w, r, req, expires)
|
// The signature covers the format auto, not the format chosen
|
||||||
|
if !s.chooseAutoFormat(w, r, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache key for logging
|
||||||
|
cacheKey := imagerequest.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +82,11 @@ 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 image request, and returns it with the URL's signature and expiry. On
|
// an image request. On invalid input it writes an error response and
|
||||||
// invalid input it writes an error response and returns false.
|
// returns false.
|
||||||
func (s *Handlers) parseImageRequest(
|
func (s *Handlers) parseImageRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imagerequest.Request, string, time.Time, bool) {
|
) (*imagerequest.Request, 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
|
||||||
@@ -88,7 +100,7 @@ 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, "", time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse signature params from query string. r.URL.Query() would silently
|
// Parse signature params from query string. r.URL.Query() would silently
|
||||||
@@ -101,7 +113,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, "", time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, values := range query {
|
for name, values := range query {
|
||||||
@@ -109,15 +121,17 @@ 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, "", time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expires, err := parseExpires(query)
|
req.Signature = query.Get("sig")
|
||||||
|
|
||||||
|
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, "", time.Time{}, false
|
return nil, 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
|
||||||
@@ -129,7 +143,7 @@ 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, "", time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Transform.Quality, err = parseFormInt(query, "q",
|
req.Transform.Quality, err = parseFormInt(query, "q",
|
||||||
@@ -138,7 +152,7 @@ func (s *Handlers) parseImageRequest(
|
|||||||
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, "", time.Time{}, false
|
return nil, 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
|
||||||
@@ -148,7 +162,7 @@ func (s *Handlers) parseImageRequest(
|
|||||||
if query.Has("fit") && req.Transform.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, "", time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fit mode if not set
|
// Default fit mode if not set
|
||||||
@@ -165,40 +179,15 @@ func (s *Handlers) parseImageRequest(
|
|||||||
s.respondError(w, "invalid image request: "+err.Error(),
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, "", time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, query.Get("sig"), expires, true
|
return req, 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 refuses
|
// missing from the URL gives the zero time, which the signature check takes
|
||||||
// as a missing exp. An exp in the URL that is not a whole number, an empty
|
// as no expiration. An exp in the URL that is not a whole number, an empty
|
||||||
// one included, is an error naming exp and the value.
|
// 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") {
|
||||||
@@ -226,101 +215,75 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondImageError answers err, the image service's error for req, on both
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
// 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 *imagerequest.Request, err error,
|
w http.ResponseWriter, req *imagerequest.Request, err error,
|
||||||
) {
|
) {
|
||||||
// The image service has logged this as a refused source
|
// An upstream body whose first bytes are not those of the format its
|
||||||
if isFirstBytesMismatch(err) {
|
// Content-Type names is the upstream's error, not pixa's: the image
|
||||||
|
// service has logged it as a refused source.
|
||||||
|
if errors.Is(err, magic.ErrMagicByteMismatch) ||
|
||||||
|
errors.Is(err, magic.ErrUnknownFormat) ||
|
||||||
|
errors.Is(err, magic.ErrNotEnoughData) {
|
||||||
s.respondError(w, "unsupported source format",
|
s.respondError(w, "unsupported source format",
|
||||||
http.StatusUnsupportedMediaType)
|
http.StatusUnsupportedMediaType)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
status int
|
|
||||||
message string
|
|
||||||
)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
|
||||||
status, message = http.StatusForbidden, "forbidden"
|
|
||||||
case errors.Is(err, httpfetcher.ErrUpstreamError),
|
|
||||||
errors.Is(err, httpfetcher.ErrInvalidHost),
|
|
||||||
errors.Is(err, httpfetcher.ErrUpstreamRequestFailed):
|
|
||||||
status, message = http.StatusBadGateway, "upstream error"
|
|
||||||
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
|
||||||
status, message = http.StatusGatewayTimeout, "upstream timeout"
|
|
||||||
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
|
||||||
errors.Is(err, imageprocessor.ErrTooManyImages),
|
|
||||||
errors.Is(err, imageproxy.ErrProcessingTimedOut):
|
|
||||||
status = http.StatusServiceUnavailable
|
|
||||||
message = "server busy, try again later"
|
|
||||||
case errors.Is(err, httpfetcher.ErrInvalidContentType),
|
|
||||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
|
|
||||||
status = http.StatusUnsupportedMediaType
|
|
||||||
message = "unsupported source format"
|
|
||||||
case errors.Is(err, imageprocessor.ErrSVGTooThin):
|
|
||||||
status = http.StatusUnsupportedMediaType
|
|
||||||
message = fmt.Sprintf("SVG source too thin to render: one side is "+
|
|
||||||
"more than %d times the other", imageprocessor.MaxInputDimension)
|
|
||||||
case errors.Is(err, imageprocessor.ErrSVGRenderFailed):
|
|
||||||
status = http.StatusUnsupportedMediaType
|
|
||||||
message = fmt.Sprintf("SVG source could not be rendered: it is not "+
|
|
||||||
"valid, or it takes more than %d seconds or %d GiB of memory to "+
|
|
||||||
"render", int(imageprocessor.SVGRenderTimeout.Seconds()),
|
|
||||||
imageprocessor.MaxSVGRenderMemory/bytesPerGiB)
|
|
||||||
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
|
||||||
status = http.StatusBadRequest
|
|
||||||
message = fmt.Sprintf("size too large for the source: the side "+
|
|
||||||
"computed from its proportions is above %d",
|
|
||||||
imageprocessor.MaxOutputDimension)
|
|
||||||
case errors.Is(err, imageprocessor.ErrSVGNotOriginalSize):
|
|
||||||
status = http.StatusBadRequest
|
|
||||||
message = "an SVG source is returned as SVG only at the orig size; " +
|
|
||||||
"ask for another format to resize it"
|
|
||||||
case errors.Is(err, imageprocessor.ErrSVGTooLargeForOrigSize):
|
|
||||||
status = http.StatusBadRequest
|
|
||||||
message = fmt.Sprintf("SVG source above %d on a side: give a size "+
|
|
||||||
"to render it at", 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",
|
s.log.Error("failed to get image",
|
||||||
"host", req.Source.Host,
|
"host", req.Source.Host,
|
||||||
"path", req.Source.Path,
|
"path", req.Source.Path,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
||||||
|
// Check for specific error types
|
||||||
|
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||||
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Warn("failed to get image",
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
"host", req.Source.Host,
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
"path", req.Source.Path,
|
|
||||||
"error", err,
|
return
|
||||||
)
|
|
||||||
s.respondError(w, message, status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bytesPerGiB is the number of bytes in a GiB.
|
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
||||||
const bytesPerGiB = 1 << 30
|
errors.Is(err, imageprocessor.ErrTooManyImages) ||
|
||||||
|
errors.Is(err, imgcache.ErrProcessingTimedOut) {
|
||||||
|
s.respondError(w, "server busy, try again later",
|
||||||
|
http.StatusServiceUnavailable)
|
||||||
|
|
||||||
// isFirstBytesMismatch reports whether err refuses an upstream body whose
|
return
|
||||||
// first bytes are not those of the format its Content-Type names. That is the
|
}
|
||||||
// upstream's error, not pixa's.
|
|
||||||
func isFirstBytesMismatch(err error) bool {
|
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
|
||||||
return errors.Is(err, magic.ErrMagicByteMismatch) ||
|
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
|
||||||
errors.Is(err, magic.ErrUnknownFormat) ||
|
s.respondError(w, "unsupported source format",
|
||||||
errors.Is(err, magic.ErrNotEnoughData)
|
http.StatusUnsupportedMediaType)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
|
||||||
|
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
||||||
|
"computed from its proportions is above %d",
|
||||||
|
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge) {
|
||||||
|
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
||||||
|
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cacheControl returns the Cache-Control header for an image served through a
|
// cacheControl returns the Cache-Control header for an image served through a
|
||||||
@@ -341,26 +304,6 @@ func cacheControl(expires time.Time) string {
|
|||||||
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
// svgContentSecurityPolicyValue is the Content-Security-Policy header value
|
|
||||||
// of an SVG response, in place of the middleware's. A browser that opens the
|
|
||||||
// SVG applies its styles and draws its embedded data: images, but runs none
|
|
||||||
// of its scripts and loads nothing else. The styles need unsafe-inline, as an
|
|
||||||
// SVG keeps them inline, in style elements and attributes.
|
|
||||||
const svgContentSecurityPolicyValue = "default-src 'none'; " +
|
|
||||||
"style-src 'unsafe-inline'; " +
|
|
||||||
"img-src data:; " +
|
|
||||||
"frame-ancestors 'none'; " +
|
|
||||||
"sandbox"
|
|
||||||
|
|
||||||
// setSVGContentSecurityPolicy sets the Content-Security-Policy in header to
|
|
||||||
// svgContentSecurityPolicyValue when contentType is that of SVG. Any other
|
|
||||||
// response keeps the middleware's policy.
|
|
||||||
func setSVGContentSecurityPolicy(header http.Header, contentType string) {
|
|
||||||
if contentType == imagerequest.FormatSVG.MIMEType() {
|
|
||||||
header.Set("Content-Security-Policy", svgContentSecurityPolicyValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
|
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
|
||||||
// names a host on referer_blocklist, and reports whether it answered. A request
|
// names a host on referer_blocklist, and reports whether it answered. A request
|
||||||
// with no Referer, or one that does not parse as a URL with a host, is not
|
// with no Referer, or one that does not parse as a URL with a host, is not
|
||||||
@@ -397,60 +340,22 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveImage serves the image that req asks for. Both image routes call it
|
// writeImageResponse writes headers and streams the image content,
|
||||||
// once they have the request and its URL's expiry, which ends the
|
// handling conditional and HEAD requests.
|
||||||
// Cache-Control max-age. It chooses the format for the format auto, answers
|
func (s *Handlers) writeImageResponse(
|
||||||
// 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 *imagerequest.Request, expires time.Time,
|
req *imagerequest.Request, resp *imgcache.ImageResponse,
|
||||||
|
cacheKey imagerequest.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)
|
||||||
setSVGContentSecurityPolicy(w.Header(), resp.ContentType)
|
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 {
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", cacheControl(expires))
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if notModified(w, r, resp.ETag) {
|
if notModified(w, r, resp.ETag) {
|
||||||
@@ -467,28 +372,18 @@ func (s *Handlers) serveImage(
|
|||||||
// 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.Warn("failed to write response",
|
s.log.Error("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", imagerequest.CacheKey(req),
|
"cache_key", cacheKey,
|
||||||
"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.Transform.Format,
|
"format", req.Transform.Format,
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ 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/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.
|
||||||
@@ -36,46 +30,37 @@ func newSignedHostServer(
|
|||||||
) (*Handlers, http.Handler) {
|
) (*Handlers, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
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 := imageproxy.New(&imageproxy.Config{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(fstest.MapFS{
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
||||||
}),
|
}),
|
||||||
Processor: imageprocessor.New(imageprocessor.Params{
|
SigningKey: testSigningKey,
|
||||||
MaxInputBytes: 50 << 20,
|
|
||||||
MaxConcurrentProcessing: 2,
|
|
||||||
}),
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
Logger: log,
|
Logger: log,
|
||||||
}, prometheus.NewRegistry())
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imageproxy.New() error = %v", err)
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("encurl.NewGenerator() 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()
|
||||||
@@ -121,9 +106,9 @@ 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()
|
||||||
|
|
||||||
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: photoPath,
|
Path: photoPath,
|
||||||
@@ -133,6 +118,9 @@ func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
|||||||
Format: imagerequest.FormatJPEG,
|
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 {
|
||||||
@@ -185,13 +173,13 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
ttl time.Duration
|
expires time.Time
|
||||||
wantAtLeast int
|
wantAtLeast int
|
||||||
wantAtMost int
|
wantAtMost int
|
||||||
}{
|
}{
|
||||||
{"60 second TTL", time.Minute, 50, 60},
|
{"60 second TTL", time.Now().Add(time.Minute), 50, 60},
|
||||||
{"two-year TTL", 2 * 365 * 24 * time.Hour, 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 {
|
||||||
@@ -200,14 +188,6 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
|
|
||||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
// The expiry is set here, not in the table: a parallel subtest
|
|
||||||
// can wait behind other tests for more than the 10 seconds the
|
|
||||||
// 60 second case allows.
|
|
||||||
var expires time.Time
|
|
||||||
if tt.ttl != 0 {
|
|
||||||
expires = time.Now().Add(tt.ttl)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := h.encGen.Generate(&imagerequest.Request{
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
@@ -217,7 +197,8 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
|
|||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
},
|
},
|
||||||
}, expires)
|
Expires: tt.expires,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
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"
|
||||||
"syscall"
|
|
||||||
"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/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"
|
||||||
@@ -34,38 +24,29 @@ 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 which checks signatures with testSigningKey.
|
// with fetcher and 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.Config{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
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 := imageproxy.New(&imageproxy.Config{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
Processor: imageprocessor.New(imageprocessor.Params{
|
SigningKey: testSigningKey,
|
||||||
MaxInputBytes: 50 << 20,
|
Allowlist: []string{allowlistedHost},
|
||||||
MaxConcurrentProcessing: 2,
|
})
|
||||||
}),
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
}, prometheus.NewRegistry())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create proxy: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handlers{
|
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
||||||
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())
|
||||||
@@ -74,7 +55,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 source with an upstream error.
|
// each of hosts, and answers any other URL 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()
|
||||||
|
|
||||||
@@ -101,10 +82,10 @@ 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
|
// photoSignature returns the signature of photoURL(host) at the default
|
||||||
// quality and fit.
|
// quality and fit, made with key and expiring at expires.
|
||||||
func photoRequest(host string) *imagerequest.Request {
|
func photoSignature(key, host string, expires time.Time) string {
|
||||||
return &imagerequest.Request{
|
return signature.New(key).Sign(&imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: host,
|
Host: host,
|
||||||
Path: photoPath,
|
Path: photoPath,
|
||||||
@@ -115,13 +96,8 @@ func photoRequest(host string) *imagerequest.Request {
|
|||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}
|
Expires: expires,
|
||||||
}
|
})
|
||||||
|
|
||||||
// photoSignature returns the signature of photoURL(host) at the default
|
|
||||||
// quality and fit, made with key and expiring at expires.
|
|
||||||
func photoSignature(key, host string, expires time.Time) string {
|
|
||||||
return signature.New(key).Sign(photoRequest(host), 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.
|
||||||
@@ -192,12 +168,7 @@ 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.Config{
|
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
||||||
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)
|
||||||
@@ -302,9 +273,10 @@ 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. Later steps put the host
|
// around something that is not an IPv6 address. A "?" or "#" after
|
||||||
// unparsed into the allowlist check, the cache key and the upstream URL, so
|
// allowlistedHost would otherwise end the host when the source URL is read,
|
||||||
// such a host is refused first.
|
// so the request would pass the allowlist and be fetched and cached under a
|
||||||
|
// host that is not the one fetched.
|
||||||
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -375,9 +347,10 @@ 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.
|
// each is refused with 400, the one signed for that source path included. The
|
||||||
// A "#" starts a URL's fragment, so neither the source path nor the source
|
// source URL would otherwise end at the "#" when it is fetched, so each text
|
||||||
// query may hold one.
|
// after it would fetch the image at photoPath and cache it under keys of its
|
||||||
|
// own.
|
||||||
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -395,7 +368,8 @@ func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
|||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}, exp)
|
Expires: exp,
|
||||||
|
})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -421,175 +395,3 @@ 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},
|
|
||||||
{"upstream host with no address",
|
|
||||||
fmt.Errorf("%w: cdn.example.com", httpfetcher.ErrInvalidHost),
|
|
||||||
http.StatusBadGateway, slog.LevelWarn},
|
|
||||||
{"connection refused",
|
|
||||||
fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamRequestFailed,
|
|
||||||
syscall.ECONNREFUSED),
|
|
||||||
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/tiff", 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,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,47 +36,6 @@ 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.
|
||||||
@@ -97,7 +55,8 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}, expires)
|
Expires: expires,
|
||||||
|
})
|
||||||
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
||||||
signedHost, sig, expires.Unix())
|
signedHost, sig, expires.Unix())
|
||||||
|
|
||||||
@@ -124,8 +83,8 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
||||||
// generateSignedURL to the route.
|
// service's signed-URL generator to the route.
|
||||||
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -142,8 +101,9 @@ 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()
|
||||||
|
|
||||||
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
fix := setupTestHandler(t)
|
||||||
&imagerequest.Request{
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: "/images/photo.jpg",
|
Path: "/images/photo.jpg",
|
||||||
@@ -155,8 +115,11 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|||||||
FitMode: tt.fitMode,
|
FitMode: tt.fitMode,
|
||||||
},
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
status := getImage(t, setupTestHandler(t), signedURL)
|
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)
|
||||||
}
|
}
|
||||||
@@ -215,10 +178,10 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
||||||
// by generateSignedURL for source URLs with a query to the route, through a
|
// by the service's signed-URL generator for source URLs with a query to the
|
||||||
// chi router: chi routes on the path as the client wrote it whenever that is
|
// route, through a chi router: chi routes on the path as the client wrote it
|
||||||
// not Go's own encoding of it. A 502 means the signature passed, so the route
|
// whenever that is not Go's own encoding of it. A 502 means the signature
|
||||||
// read the source path and query that were signed.
|
// passed, so the route 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()
|
||||||
|
|
||||||
@@ -238,8 +201,9 @@ 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()
|
||||||
|
|
||||||
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
fix := setupTestHandler(t)
|
||||||
&imagerequest.Request{
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: photoPath,
|
Path: photoPath,
|
||||||
@@ -250,8 +214,11 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
|||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
},
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
status := getImage(t, setupTestHandler(t), signedURL)
|
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)
|
||||||
}
|
}
|
||||||
@@ -280,7 +247,8 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}, expires)
|
Expires: expires,
|
||||||
|
})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -312,7 +280,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()
|
||||||
|
|
||||||
@@ -346,7 +314,8 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}, expires)
|
Expires: 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())
|
||||||
@@ -357,11 +326,12 @@ 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()
|
||||||
|
|
||||||
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
fix := setupTestHandler(t)
|
||||||
&imagerequest.Request{
|
|
||||||
|
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: tt.sourcePath,
|
Path: tt.sourcePath,
|
||||||
@@ -372,8 +342,11 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
},
|
},
|
||||||
}, time.Hour)
|
}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
status := getImage(t, setupTestHandler(t), signedURL)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-17
@@ -2,13 +2,21 @@ 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/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"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
|
||||||
@@ -20,59 +28,157 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req, expires, ok := s.parseImageEncRequest(w, r)
|
ctx := r.Context()
|
||||||
if !ok {
|
start := time.Now()
|
||||||
|
|
||||||
|
req, ok := s.parseImageEncRequest(w, r)
|
||||||
|
if !ok || !s.chooseAutoFormat(w, r, req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.serveImage(w, r, req, expires)
|
// Log the request
|
||||||
|
s.log.Debug("encrypted image request",
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"dimensions", fmt.Sprintf("%dx%d",
|
||||||
|
req.Transform.Size.Width, req.Transform.Size.Height),
|
||||||
|
"format", req.Transform.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", imagerequest.CacheKey(req),
|
||||||
|
"host", req.Source.Host,
|
||||||
|
"path", req.Source.Path,
|
||||||
|
"format", req.Transform.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
|
||||||
// image request and checks it, and returns it with the token's expiry. On a
|
// image request and checks it. On a token that is missing, does not decrypt,
|
||||||
// token that is missing, does not decrypt, has expired or asks for something
|
// has expired or asks for something not valid, it writes an error response
|
||||||
// not valid, it writes an error response and returns false.
|
// and returns false.
|
||||||
func (s *Handlers) parseImageEncRequest(
|
func (s *Handlers) parseImageEncRequest(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request,
|
||||||
) (*imagerequest.Request, time.Time, bool) {
|
) (*imagerequest.Request, bool) {
|
||||||
// Extract token from URL
|
// Extract token from URL
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
s.respondError(w, "missing token", http.StatusBadRequest)
|
s.respondError(w, "missing token", http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and validate the payload
|
// Decrypt and validate the payload
|
||||||
req, expires, err := s.encGen.Parse(token)
|
req, err := s.encGen.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, encurl.ErrExpired) {
|
if errors.Is(err, encurl.ErrExpired) {
|
||||||
s.log.Debug("encrypted URL expired", "error", err)
|
s.log.Debug("encrypted URL expired", "error", err)
|
||||||
s.respondError(w, "URL has expired", http.StatusGone)
|
s.respondError(w, "URL has expired", http.StatusGone)
|
||||||
|
|
||||||
return nil, time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Debug("invalid encrypted URL", "error", err)
|
s.log.Debug("failed to decrypt URL", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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. This refuses an over-limit size before the source is
|
// within limits, so an over-limit size or unknown fit mode is a 400
|
||||||
// fetched. An unknown fit mode is a 400 here rather than a 500 from the
|
// here rather than an out-of-memory or a 500 from the processor.
|
||||||
// processor.
|
|
||||||
err = imagerequest.ValidateImageRequest(req)
|
err = imagerequest.ValidateImageRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Debug("encrypted URL failed validation", "error", err)
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return nil, time.Time{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, expires, true
|
return req, 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,13 +6,11 @@ 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/imagerequest"
|
"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
|
||||||
@@ -22,13 +20,11 @@ import (
|
|||||||
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("encurl.NewGenerator() 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,
|
||||||
@@ -66,7 +62,7 @@ func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|||||||
Transform: imagerequest.Transform{
|
Transform: imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: 100000, Height: 100000},
|
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)
|
||||||
}
|
}
|
||||||
@@ -95,7 +91,7 @@ func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
||||||
FitMode: imagerequest.Fit("bogus"),
|
FitMode: imagerequest.Fit("bogus"),
|
||||||
},
|
},
|
||||||
}, time.Time{})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"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
|
||||||
@@ -71,24 +70,25 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := h.encGen.Generate(&photo, time.Time{})
|
valid, err := h.encGen.Generate(&photo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expired, err := h.encGen.Generate(&photo, time.Now().Add(-time.Minute))
|
expiredPhoto := photo
|
||||||
|
expiredPhoto.Expires = time.Now().Add(-time.Minute)
|
||||||
|
|
||||||
|
expired, err := h.encGen.Generate(&expiredPhoto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys("another-signing-key-fedcba9876543210")
|
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
otherGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
otherKey, err := otherGen.Generate(&photo)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
|
|||||||
Quality: encurl.DefaultQuality,
|
Quality: encurl.DefaultQuality,
|
||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
},
|
},
|
||||||
}, expires)
|
Expires: 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"} {
|
||||||
@@ -93,7 +94,7 @@ func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
|
|||||||
Transform: imagerequest.Transform{
|
Transform: imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,10 @@ 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
|
||||||
@@ -35,12 +29,11 @@ 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, source imagerequest.Source,
|
ctx context.Context, url string,
|
||||||
ifNoneMatch, ifModifiedSince string,
|
|
||||||
) (*httpfetcher.FetchResult, error) {
|
) (*httpfetcher.FetchResult, error) {
|
||||||
f.fetches.Add(1)
|
f.fetches.Add(1)
|
||||||
|
|
||||||
return f.Fetcher.Fetch(ctx, source, ifNoneMatch, ifModifiedSince)
|
return f.Fetcher.Fetch(ctx, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRefererRoutes returns both image routes of a Handlers whose
|
// newRefererRoutes returns both image routes of a Handlers whose
|
||||||
@@ -54,41 +47,33 @@ 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.Config{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
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 := imageproxy.New(&imageproxy.Config{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: fetcher,
|
Fetcher: fetcher,
|
||||||
Processor: imageprocessor.New(imageprocessor.Params{
|
SigningKey: testSigningKey,
|
||||||
MaxInputBytes: 50 << 20,
|
Allowlist: []string{allowlistedHost},
|
||||||
MaxConcurrentProcessing: 2,
|
})
|
||||||
}),
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
}, prometheus.NewRegistry())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("imageproxy.New() error = %v", err)
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := seal.DeriveKeys(testSigningKey)
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
t.Fatalf("encurl.NewGenerator() 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"}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 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. It is signed with testSigningKey,
|
// JPEG at photoPath on signedHost at 50x50, made with h's image service.
|
||||||
// the key the handlers that newSignedHostServer builds check signatures with.
|
func signedPhotoURL(t *testing.T, h *Handlers) string {
|
||||||
// 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()
|
||||||
|
|
||||||
return generateSignedURL(testSigningKey, &imagerequest.Request{
|
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
|
||||||
Source: imagerequest.Source{
|
Source: imagerequest.Source{
|
||||||
Host: signedHost,
|
Host: signedHost,
|
||||||
Path: photoPath,
|
Path: photoPath,
|
||||||
@@ -33,6 +31,11 @@ func signedPhotoURL(t *testing.T, _ *Handlers) string {
|
|||||||
Format: imagerequest.FormatJPEG,
|
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
|
||||||
@@ -49,7 +52,7 @@ func encPhotoURL(t *testing.T, h *Handlers) string {
|
|||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
},
|
},
|
||||||
}, time.Time{})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate() error = %v", err)
|
t.Fatalf("Generate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ 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/imageproxy"
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestServerBusyAnswers503 checks that respondImageError, which answers the
|
// TestServerBusyAnswers503 checks that both image routes answer 503 with
|
||||||
// errors of both image routes, answers 503 with a clear error when the image
|
// a clear error when the image service gives up waiting for a free upstream
|
||||||
// service gives up waiting for a free upstream connection or processing slot,
|
// connection or processing slot, wrapped as the service wraps them.
|
||||||
// wrapped as the service wraps them.
|
|
||||||
func TestServerBusyAnswers503(t *testing.T) {
|
func TestServerBusyAnswers503(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -31,44 +30,61 @@ func TestServerBusyAnswers503(t *testing.T) {
|
|||||||
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),
|
||||||
} {
|
} {
|
||||||
rec := httptest.NewRecorder()
|
plain := httptest.NewRecorder()
|
||||||
h.respondImageError(rec, req, err)
|
h.respondImageError(plain, req, err)
|
||||||
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for route, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"/v1/image/": plain, "/v1/e/": encrypted,
|
||||||
|
} {
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("for %v: status = %d, want %d",
|
t.Errorf("%s for %v: status = %d, want %d",
|
||||||
err, rec.Code, http.StatusServiceUnavailable)
|
route, err, rec.Code, http.StatusServiceUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
t.Errorf("for %v: body = %q, want the server busy error",
|
t.Errorf("%s for %v: body = %q, want the server busy error",
|
||||||
err, rec.Body.String())
|
route, err, rec.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessingTimedOutAnswers503 checks that respondImageError, which
|
// TestProcessingTimedOutAnswers503 checks that respondImageError and
|
||||||
// answers the errors of both image routes, answers 503 with the server busy
|
// handleImageError, which answer the errors of the two image routes, answer
|
||||||
// error when the image service reports that processing stopped at the
|
// 503 with the server busy error when the image service reports that
|
||||||
// deadline of the request doing it, wrapped as the service wraps it. A request
|
// processing stopped at the deadline of the request doing it, wrapped as the
|
||||||
// that waited for that processing gets this error while its own deadline may
|
// service wraps it. A request that waited for that processing gets this error
|
||||||
// have time left. The request's source only goes into a log line, so it is
|
// while its own deadline may have time left. The request's source only goes
|
||||||
// left empty.
|
// into a log line, so it is 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",
|
||||||
imageproxy.ErrProcessingTimedOut, context.DeadlineExceeded)
|
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
plain := httptest.NewRecorder()
|
||||||
h.respondImageError(rec, &imagerequest.Request{}, err)
|
h.respondImageError(plain, &imagerequest.Request{}, err)
|
||||||
t.Logf("status = %d, body = %q", rec.Code, rec.Body.String())
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"respondImageError": plain, "handleImageError": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
if rec.Code != http.StatusServiceUnavailable {
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("status = %d, want %d", 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") {
|
if !strings.Contains(rec.Body.String(), "server busy, try again later") {
|
||||||
t.Errorf("body = %q, want the server busy error", rec.Body.String())
|
t.Errorf("%s: body = %q, want the server busy error",
|
||||||
|
name, rec.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,101 +13,44 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
|
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
|
||||||
// answers the errors of both image routes, answers 415 with a clear error when
|
// 415 with a clear error when the upstream answers with a content type pixa
|
||||||
// the upstream answers with a content type pixa does not accept, such as TIFF,
|
// does not accept, such as SVG, and when orig is asked of a source in a
|
||||||
// and when libvips does not read a source as the format its first bytes
|
// format the processor cannot name, wrapped as the service wraps them.
|
||||||
// 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 := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.tiff"},
|
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/tiff",
|
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
||||||
httpfetcher.ErrInvalidContentType),
|
httpfetcher.ErrInvalidContentType),
|
||||||
fmt.Errorf("image processing failed: %w: libvips does not read the input as avif",
|
fmt.Errorf("image processing failed: %w: unknown",
|
||||||
imageprocessor.ErrUnsupportedInputFormat),
|
imageprocessor.ErrUnsupportedInputFormat),
|
||||||
} {
|
} {
|
||||||
rec := httptest.NewRecorder()
|
plain := httptest.NewRecorder()
|
||||||
h.respondImageError(rec, req, err)
|
h.respondImageError(plain, req, err)
|
||||||
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
|
||||||
|
encrypted := httptest.NewRecorder()
|
||||||
|
h.handleImageError(encrypted, err)
|
||||||
|
|
||||||
|
for route, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"HandleImage": plain, "HandleImageEnc": encrypted,
|
||||||
|
} {
|
||||||
|
t.Logf("%s for %v: %d %s", route, err, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
if rec.Code != http.StatusUnsupportedMediaType {
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
t.Errorf("for %v: status = %d, want %d",
|
t.Errorf("%s for %v: status = %d, want %d",
|
||||||
err, rec.Code, http.StatusUnsupportedMediaType)
|
route, err, rec.Code, http.StatusUnsupportedMediaType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
if !strings.Contains(rec.Body.String(), "unsupported source format") {
|
||||||
t.Errorf("for %v: body = %q, want the unsupported source format error",
|
t.Errorf("%s for %v: body = %q, want the unsupported source format error",
|
||||||
err, rec.Body.String())
|
route, err, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSVGTooThinAnswers415 checks that respondImageError answers 415 with a
|
|
||||||
// clear error for an SVG source too thin to render, wrapped as the service
|
|
||||||
// wraps it.
|
|
||||||
func TestSVGTooThinAnswers415(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{Host: "svg.example.com", Path: "/a.svg"},
|
|
||||||
}
|
|
||||||
err := fmt.Errorf("image processing failed: %w: 81930x10",
|
|
||||||
imageprocessor.ErrSVGTooThin)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.respondImageError(rec, req, err)
|
|
||||||
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnsupportedMediaType {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code,
|
|
||||||
http.StatusUnsupportedMediaType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "SVG source too thin to render") {
|
|
||||||
t.Errorf("body = %q, want the SVG too thin error", rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSVGRenderFailedAnswers415 checks that respondImageError answers 415,
|
|
||||||
// naming the limits, for an SVG source that a child process could not read
|
|
||||||
// or render, wrapped as the service wraps it.
|
|
||||||
func TestSVGRenderFailedAnswers415(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
|
||||||
err := fmt.Errorf("image processing failed: %w: it took more than 20s",
|
|
||||||
imageprocessor.ErrSVGRenderFailed)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.respondImageError(rec, &imagerequest.Request{}, err)
|
|
||||||
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
checkErrorBody(t, rec, http.StatusUnsupportedMediaType,
|
|
||||||
"SVG source could not be rendered: it is not valid, or it takes more "+
|
|
||||||
"than 20 seconds or 1 GiB of memory to render")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSVGTooLargeForOrigSizeAnswers400 checks that respondImageError answers
|
|
||||||
// 400, asking for a size, for an SVG source above 8192 on a side asked for at
|
|
||||||
// the orig size in a raster format, wrapped as the service wraps it.
|
|
||||||
func TestSVGTooLargeForOrigSizeAnswers400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
|
||||||
err := fmt.Errorf("image processing failed: %w: 10000x10 is above 8192",
|
|
||||||
imageprocessor.ErrSVGTooLargeForOrigSize)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.respondImageError(rec, &imagerequest.Request{}, err)
|
|
||||||
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
checkErrorBody(t, rec, http.StatusBadRequest,
|
|
||||||
"SVG source above 8192 on a side: give a size to render it at")
|
|
||||||
}
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
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,7 +7,6 @@ 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
|
||||||
@@ -71,15 +70,12 @@ func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
|||||||
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
transport := transportOf(t, New(&Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
// 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.
|
// blocking it can only come from the operator-supplied list.
|
||||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")},
|
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||||
}))
|
|
||||||
|
transport := transportOf(t, New(cfg))
|
||||||
|
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"198.51.100.5:80", // operator-supplied range
|
"198.51.100.5:80", // operator-supplied range
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
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,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
// TestNewUsesCheckedDialerWithoutDialContext checks that a fetcher built
|
||||||
@@ -14,12 +13,7 @@ func TestNewUsesCheckedDialerWithoutDialContext(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
transport := transportOf(t, New(&Config{
|
transport := transportOf(t, New(DefaultConfig()))
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
}))
|
|
||||||
|
|
||||||
addr := srv.Listener.Addr().String()
|
addr := srv.Listener.Addr().String()
|
||||||
|
|
||||||
@@ -38,25 +32,21 @@ 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()}
|
||||||
|
|
||||||
f := New(&Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.AllowHTTP = true
|
||||||
MaxResponseSize: 50 << 20,
|
cfg.DialContext = dialer.dialContext
|
||||||
AllowHTTP: true,
|
f := New(cfg)
|
||||||
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), imageSourceOn("127.0.0.1"), "", "")
|
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
|
||||||
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), upstreamSource("/redirect/private"), "", "")
|
_, err = f.Fetch(testContext(t), upstreamURL("/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,8 +14,6 @@ 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
|
||||||
@@ -31,9 +29,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")
|
||||||
|
|
||||||
// upstreamSource returns the source at path on the fake public host.
|
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
||||||
func upstreamSource(path string) imagerequest.Source {
|
func upstreamURL(path string) string {
|
||||||
return imagerequest.Source{Host: testPublicHost, Path: path}
|
return "http://" + testPublicHost + path
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordingDialer records every address the transport asks it to dial and
|
// recordingDialer records every address the transport asks it to dial and
|
||||||
@@ -83,7 +81,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", imagerequest.FormatJPEG.MIMEType())
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
_, _ = 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) {
|
||||||
@@ -122,12 +120,7 @@ func newServerFetcher(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = &Config{
|
cfg = DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.AllowHTTP = true
|
cfg.AllowHTTP = true
|
||||||
@@ -160,7 +153,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), upstreamSource(path), "", "")
|
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch(%s) error = %v", path, err)
|
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +165,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), upstreamSource(path), "", "")
|
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = res.Content.Close()
|
_ = res.Content.Close()
|
||||||
|
|
||||||
@@ -224,7 +217,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), upstreamSource("/redirect/private"), "", "")
|
_, err := f.Fetch(testContext(t), upstreamURL("/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)
|
||||||
}
|
}
|
||||||
@@ -287,12 +280,8 @@ func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxResponseSize = 8
|
||||||
MaxResponseSize: 8,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -321,12 +310,8 @@ func fetchWithMaxResponseSize(
|
|||||||
srv := httptest.NewServer(handler)
|
srv := httptest.NewServer(handler)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxResponseSize = maxResponseSize
|
||||||
MaxResponseSize: maxResponseSize,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -358,7 +343,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", imagerequest.FormatJPEG.MIMEType())
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
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)
|
||||||
})
|
})
|
||||||
@@ -406,7 +391,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", imagerequest.FormatJPEG.MIMEType())
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
_, _ = 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.
|
||||||
@@ -451,12 +436,8 @@ func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxConnectionsPerHost = 1
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 1,
|
|
||||||
MaxConnections: 64,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -485,12 +466,8 @@ func assertSlotReleasedByClose(
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxConnectionsPerHost = 1
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 1,
|
|
||||||
MaxConnections: 64,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
@@ -547,7 +524,7 @@ func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dial-time re-resolution in dialSSRFSafe is what closes the DNS
|
// The dial-time re-resolution in ssrfSafeDialer 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
|
||||||
@@ -567,9 +544,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 := dialSSRFSafe(context.Background(), "tcp", addr, nil)
|
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
t.Errorf("dialSSRFSafe(%q) = %v, want ErrSSRFBlocked", addr, err)
|
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -583,7 +560,7 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
_, err := dialSSRFSafe(ctx, "tcp", testPublicHost+":80", nil)
|
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -592,53 +569,3 @@ 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+109
-150
@@ -20,24 +20,36 @@ 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
|
||||||
|
DefaultMaxResponseSize = 50 << 20 // 50MB
|
||||||
DefaultTLSTimeout = 10 * time.Second
|
DefaultTLSTimeout = 10 * time.Second
|
||||||
DefaultMaxIdleConns = 100
|
DefaultMaxIdleConns = 100
|
||||||
DefaultIdleConnTimeout = 90 * time.Second
|
DefaultIdleConnTimeout = 90 * time.Second
|
||||||
DefaultMaxRedirects = 10
|
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"
|
||||||
@@ -69,10 +81,6 @@ var (
|
|||||||
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
||||||
ErrUpstreamError = errors.New("upstream server error")
|
ErrUpstreamError = errors.New("upstream server error")
|
||||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||||
// ErrUpstreamRequestFailed is a fetch that failed before the upstream
|
|
||||||
// answered, other than by timeout or cancellation: a failed connection
|
|
||||||
// or TLS handshake, or a redirect the fetcher refuses.
|
|
||||||
ErrUpstreamRequestFailed = errors.New("upstream request failed")
|
|
||||||
ErrTooManyConnections = errors.New("too many concurrent upstream connections")
|
ErrTooManyConnections = errors.New("too many concurrent upstream connections")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,13 +92,8 @@ var (
|
|||||||
|
|
||||||
// Fetcher retrieves content from upstream origins.
|
// Fetcher retrieves content from upstream origins.
|
||||||
type Fetcher interface {
|
type Fetcher interface {
|
||||||
// Fetch retrieves the content of the source image. A non-empty
|
// Fetch retrieves content from the given URL.
|
||||||
// ifNoneMatch or ifModifiedSince is sent as the request header of that
|
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||||
// 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.
|
||||||
@@ -117,30 +120,17 @@ type FetchResult struct {
|
|||||||
TLSCipherSuite string
|
TLSCipherSuite string
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedContentTypes returns the content types an upstream response may
|
// Config holds configuration for the upstream fetcher.
|
||||||
// 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(),
|
|
||||||
imagerequest.FormatSVG.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
|
||||||
// AllowHTTP makes the fetcher fetch sources over http instead of https,
|
// UserAgent to send to upstream servers.
|
||||||
// and follow redirects to http (for testing only).
|
UserAgent string
|
||||||
|
// 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
|
||||||
@@ -157,6 +147,26 @@ 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 {
|
||||||
@@ -184,6 +194,10 @@ 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
|
||||||
@@ -212,7 +226,7 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the redirect target
|
// Validate the redirect target
|
||||||
err := validateURL(req.Context(), req.URL, config.AllowHTTP)
|
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("redirect blocked: %w", err)
|
return fmt.Errorf("redirect blocked: %w", err)
|
||||||
}
|
}
|
||||||
@@ -230,26 +244,18 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves the source image with SSRF protection, over https, or over
|
// Fetch retrieves content from the given URL with SSRF protection. When
|
||||||
// http when AllowHTTP is set. When MaxConnections fetches are already in
|
// MaxConnections fetches are already in progress, it waits up to
|
||||||
// progress, it waits up to ConnectionWaitTimeout for one to finish, then
|
// ConnectionWaitTimeout for one to finish, then fails with
|
||||||
// fails with ErrTooManyConnections. A non-empty ifNoneMatch or
|
// ErrTooManyConnections.
|
||||||
// ifModifiedSince is sent as the If-None-Match or If-Modified-Since header.
|
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||||
// 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, sourceURL, f.config.AllowHTTP)
|
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := f.acquireConnection(ctx, source.Host)
|
release, err := f.acquireConnection(ctx, extractHost(url))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -263,10 +269,25 @@ func (f *HTTPFetcher) Fetch(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
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: sourceURL,
|
URL: parsedURL,
|
||||||
Header: requestHeaders(ctx, ifNoneMatch, ifModifiedSince),
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", f.config.UserAgent)
|
||||||
|
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
||||||
|
|
||||||
|
// The ID of the request this fetch serves, so the fetch can be found in
|
||||||
|
// the upstream host's logs
|
||||||
|
requestID := middleware.GetReqID(ctx)
|
||||||
|
if requestID != "" {
|
||||||
|
req.Header.Set(middleware.RequestIDHeader, requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use httptrace to capture connection details
|
// Use httptrace to capture connection details
|
||||||
@@ -288,21 +309,11 @@ func (f *HTTPFetcher) Fetch(
|
|||||||
fetchDuration := time.Since(startTime)
|
fetchDuration := time.Since(startTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, upstreamRequestError(err)
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, ErrUpstreamTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deferred release gives the connection back, as no body follows
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
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)
|
||||||
@@ -317,60 +328,6 @@ func (f *HTTPFetcher) Fetch(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamRequestError returns the error Fetch reports when its upstream
|
|
||||||
// request fails with err:
|
|
||||||
// - err wrapped in ErrUpstreamTimeout for a timeout, so the log names what
|
|
||||||
// timed out.
|
|
||||||
// - err, still matching context.Canceled, when the caller cancelled the
|
|
||||||
// fetch.
|
|
||||||
// - err wrapped in ErrUpstreamRequestFailed otherwise.
|
|
||||||
func upstreamRequestError(err error) error {
|
|
||||||
var netErr net.Error
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded),
|
|
||||||
errors.Is(err, ErrUpstreamTimeout),
|
|
||||||
errors.As(err, &netErr) && netErr.Timeout():
|
|
||||||
// A timeout other than the fetch timeout, such as the TLS
|
|
||||||
// handshake timeout, does not match context.DeadlineExceeded. An
|
|
||||||
// address lookup or a connection that timed out is
|
|
||||||
// ErrUpstreamTimeout already, so its log line names that error twice.
|
|
||||||
return fmt.Errorf("%w: %w", ErrUpstreamTimeout, err)
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
// The caller cancelled the fetch; the upstream did not fail
|
|
||||||
return fmt.Errorf("upstream request cancelled: %w", err)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("%w: %w", ErrUpstreamRequestFailed, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -503,7 +460,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 allowedContentTypes() {
|
for _, allowed := range f.config.AllowedContentTypes {
|
||||||
if strings.EqualFold(mediaType, allowed) {
|
if strings.EqualFold(mediaType, allowed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -513,14 +470,19 @@ 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, fetchURL *neturl.URL, allowHTTP bool) error {
|
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||||
if !allowHTTP && fetchURL.Scheme != "https" {
|
if !allowHTTP && !strings.HasPrefix(rawURL, "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 := fetchURL.Hostname()
|
host := parsedURL.Hostname()
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return ErrInvalidHost
|
return ErrInvalidHost
|
||||||
}
|
}
|
||||||
@@ -533,7 +495,7 @@ func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) erro
|
|||||||
// Resolve the host to check IP addresses
|
// Resolve the host to check IP addresses
|
||||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return addressLookupError(host, err)
|
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
||||||
@@ -546,21 +508,23 @@ func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addressLookupError returns the error for a failed address lookup of host:
|
// extractHost extracts the host from a URL string.
|
||||||
// - ErrUpstreamTimeout when the lookup timed out.
|
func extractHost(rawURL string) string {
|
||||||
// - err, still matching context.Canceled, when the lookup was cancelled.
|
// Simple extraction without full URL parsing
|
||||||
// - ErrInvalidHost otherwise.
|
url := rawURL
|
||||||
func addressLookupError(host string, err error) error {
|
if idx := strings.Index(url, "://"); idx != -1 {
|
||||||
var netErr net.Error
|
url = url[idx+3:]
|
||||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
||||||
return fmt.Errorf("%w: address lookup of %s", ErrUpstreamTimeout, host)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, context.Canceled) {
|
if idx := strings.Index(url, "/"); idx != -1 {
|
||||||
return fmt.Errorf("address lookup cancelled: %w", err)
|
url = url[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
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.
|
||||||
@@ -675,6 +639,13 @@ 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.
|
||||||
@@ -691,7 +662,7 @@ func dialSSRFSafe(
|
|||||||
// Resolve the address
|
// Resolve the address
|
||||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, addressLookupError(host, err)
|
return nil, fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all resolved IPs
|
// Check all resolved IPs
|
||||||
@@ -702,10 +673,7 @@ func dialSSRFSafe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect using the first valid IP
|
// Connect using the first valid IP
|
||||||
var (
|
var dialer net.Dialer
|
||||||
dialer net.Dialer
|
|
||||||
lastDialErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
addr := net.JoinHostPort(ip.String(), port)
|
addr := net.JoinHostPort(ip.String(), port)
|
||||||
@@ -714,18 +682,9 @@ func dialSSRFSafe(
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastDialErr = err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the last address tried decides whether the connection timed out
|
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||||
var netErr net.Error
|
|
||||||
if errors.As(lastDialErr, &netErr) && netErr.Timeout() {
|
|
||||||
return nil, fmt.Errorf("%w: connect to %s: %w",
|
|
||||||
ErrUpstreamTimeout, host, lastDialErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w to %s: %w", errConnectFailed, host, lastDialErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitedReader wraps a reader and refuses a body longer than remaining
|
// limitedReader wraps a reader and refuses a body longer than remaining
|
||||||
|
|||||||
@@ -5,34 +5,70 @@ 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(&Config{
|
f := New(DefaultConfig())
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
})
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
contentType string
|
contentType string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{imagerequest.FormatJPEG.MIMEType(), true},
|
{contentTypeJPEG, true},
|
||||||
{imagerequest.FormatPNG.MIMEType(), true},
|
{contentTypePNG, true},
|
||||||
{imagerequest.FormatWebP.MIMEType(), true},
|
{contentTypeWebP, 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},
|
||||||
@@ -52,6 +88,33 @@ 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()
|
||||||
|
|
||||||
@@ -164,7 +227,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(), &url.URL{Scheme: "http", Host: testHost}, false)
|
err := validateURL(t.Context(), "http://example.com/path", 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)
|
||||||
}
|
}
|
||||||
@@ -174,8 +237,7 @@ 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(),
|
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
||||||
&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")
|
||||||
@@ -185,7 +247,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(), &url.URL{Scheme: "https", Host: "localhost"}, false)
|
err := validateURL(t.Context(), "https://localhost/path", 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)
|
||||||
}
|
}
|
||||||
@@ -194,7 +256,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(), &url.URL{Scheme: "https"}, false)
|
err := validateURL(t.Context(), "https:///path", 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)
|
||||||
}
|
}
|
||||||
@@ -209,15 +271,14 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
|||||||
|
|
||||||
m := NewMock(mockFS)
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
result, err := m.Fetch(context.Background(),
|
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
||||||
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 != imagerequest.FormatJPEG.MIMEType() {
|
if result.ContentType != contentTypeJPEG {
|
||||||
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +302,7 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
|||||||
mockFS := fstest.MapFS{}
|
mockFS := fstest.MapFS{}
|
||||||
m := NewMock(mockFS)
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
_, err := m.Fetch(context.Background(),
|
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -259,7 +319,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, imagerequest.Source{Host: testHost, Path: "/photo.jpg"}, "", "")
|
_, err := m.Fetch(ctx, "https://example.com/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)
|
||||||
}
|
}
|
||||||
@@ -272,14 +332,14 @@ func TestDetectContentTypeFromPath(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"foo/bar.jpg", imagerequest.FormatJPEG.MIMEType()},
|
{"foo/bar.jpg", contentTypeJPEG},
|
||||||
{"foo/bar.JPG", imagerequest.FormatJPEG.MIMEType()},
|
{"foo/bar.JPG", contentTypeJPEG},
|
||||||
{"foo/bar.jpeg", imagerequest.FormatJPEG.MIMEType()},
|
{"foo/bar.jpeg", contentTypeJPEG},
|
||||||
{"foo/bar.png", imagerequest.FormatPNG.MIMEType()},
|
{"foo/bar.png", contentTypePNG},
|
||||||
{"foo/bar.gif", imagerequest.FormatGIF.MIMEType()},
|
{"foo/bar.gif", contentTypeGIF},
|
||||||
{"foo/bar.webp", imagerequest.FormatWebP.MIMEType()},
|
{"foo/bar.webp", contentTypeWebP},
|
||||||
{"foo/bar.avif", imagerequest.FormatAVIF.MIMEType()},
|
{"foo/bar.avif", contentTypeAVIF},
|
||||||
{"foo/bar.svg", imagerequest.FormatSVG.MIMEType()},
|
{"foo/bar.svg", contentTypeSVG},
|
||||||
{"foo/bar.bin", contentTypeOctetStream},
|
{"foo/bar.bin", contentTypeOctetStream},
|
||||||
{"foo/bar", contentTypeOctetStream},
|
{"foo/bar", contentTypeOctetStream},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
package httpfetcher
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer
|
||||||
// of type image/jxl, and that the mock fetcher serves a .jxl file as
|
// of type image/jxl by default, and that the mock fetcher serves a .jxl file
|
||||||
// image/jxl.
|
// as image/jxl.
|
||||||
func TestJPEGXLContentType(t *testing.T) {
|
func TestJPEGXLContentType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const jxlType = "image/jxl"
|
const jxlType = "image/jxl"
|
||||||
|
|
||||||
f := New(&Config{
|
if !New(DefaultConfig()).isAllowedContentType(jxlType) {
|
||||||
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,20 +8,22 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// imageSourceOn is the fake upstream's image route on host.
|
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
||||||
func imageSourceOn(host string) imagerequest.Source {
|
|
||||||
return imagerequest.Source{Host: host, Path: "/image"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageSourceOnPort is the fake upstream's image route on testPublicHost at
|
|
||||||
// port. Each port is a different host to the per-host limit, while the test
|
// port. Each port is a different host to the per-host limit, while the test
|
||||||
// dialer sends every port to the one test server.
|
// dialer sends every port to the one test server.
|
||||||
func imageSourceOnPort(port int) imagerequest.Source {
|
func imageURLOnPort(port int) string {
|
||||||
return imageSourceOn(net.JoinHostPort(testPublicHost, strconv.Itoa(port)))
|
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
||||||
|
"/image"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfigMaxConnections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := DefaultConfig().MaxConnections; got != DefaultMaxConnections {
|
||||||
|
t.Errorf("MaxConnections = %d, want %d", got, DefaultMaxConnections)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
||||||
@@ -35,22 +37,18 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxConnections = 2
|
||||||
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), imageSourceOnPort(81), "", "")
|
first, err := f.Fetch(testContext(t), imageURLOnPort(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), imageSourceOnPort(82), "", "")
|
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second Fetch() error = %v", err)
|
t.Fatalf("second Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -59,7 +57,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
|||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err = f.Fetch(testContext(t), imageSourceOnPort(83), "", "")
|
_, err = f.Fetch(testContext(t), imageURLOnPort(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)
|
||||||
}
|
}
|
||||||
@@ -78,7 +76,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), imageSourceOnPort(83), "", "")
|
third, err := f.Fetch(testContext(t), imageURLOnPort(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)
|
||||||
}
|
}
|
||||||
@@ -96,16 +94,12 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxConnections = 1
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _ := newServerFetcher(t, srv, cfg)
|
f, _ := newServerFetcher(t, srv, cfg)
|
||||||
|
|
||||||
first, err := f.Fetch(testContext(t), imageSourceOnPort(81), "", "")
|
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Fetch() error = %v", err)
|
t.Fatalf("first Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -115,7 +109,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, imageSourceOnPort(82), "", "")
|
_, err = f.Fetch(ctx, imageURLOnPort(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)
|
||||||
}
|
}
|
||||||
@@ -140,7 +134,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, imageSourceOnPort(1+i%50), "", "")
|
res, err := f.Fetch(ctx, imageURLOnPort(1+i%50))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Fetch() error = %v", err)
|
t.Errorf("Fetch() error = %v", err)
|
||||||
|
|
||||||
@@ -169,22 +163,19 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxConnections = 1
|
||||||
MaxResponseSize: 50 << 20,
|
cfg.MaxConnectionsPerHost = 1
|
||||||
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), imageSourceOnPort(81), "", "")
|
open, err := f.Fetch(testContext(t), imageURLOnPort(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), imageSourceOnPort(82), "", "")
|
_, err = f.Fetch(testContext(t), imageURLOnPort(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)
|
||||||
@@ -193,7 +184,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, imageSourceOnPort(81), "", "")
|
_, err = f.Fetch(ctx, imageURLOnPort(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)
|
||||||
@@ -205,7 +196,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, imageSourceOnPort(83), "", "")
|
_, err = f.Fetch(ctx, imageURLOnPort(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)
|
||||||
@@ -229,14 +220,14 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
source imagerequest.Source
|
url string
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{"upstream answers 500", upstreamSource("/status/500"), ErrUpstreamError},
|
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
||||||
{"upstream sends HTML", upstreamSource("/html"), ErrInvalidContentType},
|
{"upstream sends HTML", upstreamURL("/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", imageSourceOn("198.51.100.7"), errUnexpectedDial},
|
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -245,17 +236,13 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
|||||||
|
|
||||||
srv := startUpstream(t)
|
srv := startUpstream(t)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := DefaultConfig()
|
||||||
Timeout: 30 * time.Second,
|
cfg.MaxConnections = 1
|
||||||
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.source, "", "")
|
_, err := f.Fetch(testContext(t), tc.url)
|
||||||
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"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errEmptyURLPath is returned when a mock URL has no usable path.
|
||||||
|
var errEmptyURLPath = errors.New("empty URL path")
|
||||||
|
|
||||||
// MockFetcher implements Fetcher using an embedded filesystem.
|
// 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
|
||||||
// The source with host example.com and path /images/photo.jpg maps to
|
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
||||||
// example.com/images/photo.jpg; the source query is ignored.
|
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
@@ -24,11 +24,8 @@ func NewMock(fsys fs.FS) *MockFetcher {
|
|||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch retrieves the source's content from the mock filesystem. It ignores
|
// Fetch retrieves content from the mock filesystem.
|
||||||
// ifNoneMatch and ifModifiedSince, and always sends the content.
|
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||||
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():
|
||||||
@@ -36,7 +33,11 @@ func (m *MockFetcher) Fetch(
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
path := source.Host + source.Path
|
// Parse URL to get filesystem 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)
|
||||||
@@ -67,9 +68,31 @@ func (m *MockFetcher) Fetch(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// contentTypeOctetStream is the content type the mock fetcher gives files in
|
// urlToFSPath converts a URL to a filesystem path.
|
||||||
// no format pixa reads.
|
// https://example.com/images/photo.jpg -> example.com/images/photo.jpg
|
||||||
const contentTypeOctetStream = "application/octet-stream"
|
func urlToFSPath(rawURL string) (string, error) {
|
||||||
|
// Strip scheme
|
||||||
|
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 {
|
||||||
@@ -77,19 +100,19 @@ 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 imagerequest.FormatJPEG.MIMEType()
|
return contentTypeJPEG
|
||||||
case strings.HasSuffix(path, ".png"):
|
case strings.HasSuffix(path, ".png"):
|
||||||
return imagerequest.FormatPNG.MIMEType()
|
return contentTypePNG
|
||||||
case strings.HasSuffix(path, ".gif"):
|
case strings.HasSuffix(path, ".gif"):
|
||||||
return imagerequest.FormatGIF.MIMEType()
|
return contentTypeGIF
|
||||||
case strings.HasSuffix(path, ".webp"):
|
case strings.HasSuffix(path, ".webp"):
|
||||||
return imagerequest.FormatWebP.MIMEType()
|
return contentTypeWebP
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return imagerequest.FormatAVIF.MIMEType()
|
return contentTypeAVIF
|
||||||
case strings.HasSuffix(path, ".jxl"):
|
case strings.HasSuffix(path, ".jxl"):
|
||||||
return imagerequest.FormatJXL.MIMEType()
|
return contentTypeJXL
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return imagerequest.FormatSVG.MIMEType()
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
return contentTypeOctetStream
|
return contentTypeOctetStream
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
package httpfetcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestFetchFailedBeforeAnswer checks the error of a fetch whose connection
|
|
||||||
// fails:
|
|
||||||
// - refused: ErrUpstreamRequestFailed.
|
|
||||||
// - timed out: ErrUpstreamTimeout, still matching the dial's error, even for
|
|
||||||
// a timeout error that does not match context.DeadlineExceeded, such as
|
|
||||||
// the TLS handshake timeout.
|
|
||||||
// - cancelled by its caller: context.Canceled, not ErrUpstreamRequestFailed.
|
|
||||||
func TestFetchFailedBeforeAnswer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
dial func(cancel context.CancelFunc) error
|
|
||||||
wantErrs []error
|
|
||||||
wantNotErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "connection refused",
|
|
||||||
dial: func(context.CancelFunc) error {
|
|
||||||
return syscall.ECONNREFUSED
|
|
||||||
},
|
|
||||||
wantErrs: []error{ErrUpstreamRequestFailed},
|
|
||||||
wantNotErr: context.Canceled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "timed out while connecting",
|
|
||||||
dial: func(context.CancelFunc) error {
|
|
||||||
return os.ErrDeadlineExceeded
|
|
||||||
},
|
|
||||||
wantErrs: []error{ErrUpstreamTimeout, os.ErrDeadlineExceeded},
|
|
||||||
wantNotErr: ErrUpstreamRequestFailed,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cancelled while connecting",
|
|
||||||
dial: func(cancel context.CancelFunc) error {
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
return context.Canceled
|
|
||||||
},
|
|
||||||
wantErrs: []error{context.Canceled},
|
|
||||||
wantNotErr: ErrUpstreamRequestFailed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(testContext(t))
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
f := New(&Config{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
AllowHTTP: true,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
|
||||||
return nil, tt.dial(cancel)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := f.Fetch(ctx, upstreamSource("/image"), "", "")
|
|
||||||
t.Logf("Fetch() error: %v", err)
|
|
||||||
|
|
||||||
for _, wantErr := range tt.wantErrs {
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Errorf("Fetch() error = %v, want %v", err, wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, tt.wantNotErr) {
|
|
||||||
t.Errorf("Fetch() error = %v, must not be %v", err, tt.wantNotErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchAddressLookupTimedOut checks that a fetch whose address lookup
|
|
||||||
// times out fails with ErrUpstreamTimeout, both for the lookup of the source
|
|
||||||
// host and for the lookup while connecting. The error must match neither
|
|
||||||
// ErrInvalidHost nor ErrUpstreamRequestFailed, which the image routes answer
|
|
||||||
// 502. A context whose deadline has passed makes the lookup time out.
|
|
||||||
func TestFetchAddressLookupTimedOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
checkTimedOut := func(t *testing.T, err error) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("Fetch() error: %v", err)
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrUpstreamTimeout) {
|
|
||||||
t.Errorf("Fetch() error = %v, want ErrUpstreamTimeout", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, ErrInvalidHost) || errors.Is(err, ErrUpstreamRequestFailed) {
|
|
||||||
t.Errorf("Fetch() error = %v, must match neither ErrInvalidHost "+
|
|
||||||
"nor ErrUpstreamRequestFailed", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("lookup of the source host", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(testContext(t), 0)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
f := New(&Config{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := f.Fetch(ctx, imageSourceOn("cdn.example.com"), "", "")
|
|
||||||
checkTimedOut(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("lookup while connecting", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The source host is an address, which validateURL does not look up;
|
|
||||||
// the dialer looks up a name
|
|
||||||
f := New(&Config{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 0)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return dialSSRFSafe(ctx, network, "img.example.com:443", nil)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), upstreamSource("/image"), "", "")
|
|
||||||
checkTimedOut(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchConnectionTimedOut checks that a fetch whose connection in
|
|
||||||
// dialSSRFSafe times out fails with ErrUpstreamTimeout, which the image
|
|
||||||
// routes answer 504. The error must match neither ErrUpstreamRequestFailed
|
|
||||||
// nor ErrInvalidHost, which they answer 502. A context whose deadline has
|
|
||||||
// passed makes the connection time out without touching the network.
|
|
||||||
func TestFetchConnectionTimedOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := New(&Config{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 0)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return dialSSRFSafe(ctx, network, addr, nil)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := f.Fetch(testContext(t), upstreamSource("/image"), "", "")
|
|
||||||
t.Logf("Fetch() error: %v", err)
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrUpstreamTimeout) {
|
|
||||||
t.Errorf("Fetch() error = %v, want ErrUpstreamTimeout", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, ErrUpstreamRequestFailed) || errors.Is(err, ErrInvalidHost) {
|
|
||||||
t.Errorf("Fetch() error = %v, must match neither "+
|
|
||||||
"ErrUpstreamRequestFailed nor ErrInvalidHost", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchAddressLookupCancelled checks that a fetch whose address lookup
|
|
||||||
// its caller cancelled fails with an error matching context.Canceled, not
|
|
||||||
// ErrInvalidHost, which the image routes answer 502.
|
|
||||||
func TestFetchAddressLookupCancelled(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(testContext(t))
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
f := New(&Config{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
MaxResponseSize: 50 << 20,
|
|
||||||
MaxConnectionsPerHost: 20,
|
|
||||||
MaxConnections: 64,
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := f.Fetch(ctx, imageSourceOn("cdn.example.com"), "", "")
|
|
||||||
t.Logf("Fetch() error: %v", err)
|
|
||||||
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, ErrInvalidHost) {
|
|
||||||
t.Errorf("Fetch() error = %v, must not be ErrInvalidHost", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ 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
|
||||||
@@ -26,7 +25,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", imagerequest.FormatJPEG.MIMEType())
|
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||||
_, _ = io.WriteString(w, imagePayload)
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -35,7 +34,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, upstreamSource("/image"), "", "")
|
res, err := f.Fetch(ctx, upstreamURL("/image"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Fetch() error = %v", err)
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,41 +39,37 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input []byte
|
input []byte
|
||||||
sourceFormat imagerequest.Format
|
|
||||||
format imagerequest.Format
|
format imagerequest.Format
|
||||||
width, height int
|
width, height int
|
||||||
fit imagerequest.Fit
|
fit imagerequest.Fit
|
||||||
refused bool
|
refused bool
|
||||||
}{
|
}{
|
||||||
{"AVIF at the limit", cutOff, imagerequest.FormatPNG,
|
{"AVIF at the limit", cutOff, imagerequest.FormatAVIF,
|
||||||
imagerequest.FormatAVIF, 4096, 4096, imagerequest.FitCover, false},
|
4096, 4096, imagerequest.FitCover, false},
|
||||||
{"AVIF above the limit", cutOff, imagerequest.FormatPNG,
|
{"AVIF above the limit", cutOff, imagerequest.FormatAVIF,
|
||||||
imagerequest.FormatAVIF, 4096, 4097, imagerequest.FitCover, true},
|
4096, 4097, imagerequest.FitCover, true},
|
||||||
{"WebP above the AVIF limit", cutOff, imagerequest.FormatPNG,
|
{"WebP above the AVIF limit", cutOff, imagerequest.FormatWebP,
|
||||||
imagerequest.FormatWebP, 4096, 4097, imagerequest.FitCover, false},
|
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, imagerequest.FormatPNG,
|
{"AVIF inside a larger size", cutOff, imagerequest.FormatAVIF,
|
||||||
imagerequest.FormatAVIF, 8192, 8192, imagerequest.FitInside, false},
|
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, imagerequest.FormatPNG,
|
{"AVIF outside a smaller size", cutOff, imagerequest.FormatAVIF,
|
||||||
imagerequest.FormatAVIF, 8192, 1, imagerequest.FitOutside, true},
|
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, imagerequest.FormatAVIF,
|
{"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
||||||
imagerequest.FormatAVIF, 0, 0, imagerequest.FitCover, true},
|
0, 0, imagerequest.FitCover, true},
|
||||||
{"orig of an AVIF source", largeAVIF, imagerequest.FormatAVIF,
|
{"orig of an AVIF source", largeAVIF, imagerequest.FormatOriginal,
|
||||||
imagerequest.FormatOriginal, 0, 0, imagerequest.FitCover, true},
|
0, 0, imagerequest.FitCover, true},
|
||||||
{"JPEG of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
{"JPEG of the source's size", largeAVIF, imagerequest.FormatJPEG,
|
||||||
imagerequest.FormatJPEG, 0, 0, imagerequest.FitCover, false},
|
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{
|
_, err := New(Params{}).Process(t.Context(), bytes.NewReader(tt.input),
|
||||||
MaxInputBytes: 50 << 20,
|
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(), bytes.NewReader(tt.input), tt.sourceFormat,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
|
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
|
||||||
Format: tt.format,
|
Format: tt.format,
|
||||||
|
|||||||
@@ -38,11 +38,8 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
@@ -107,11 +104,8 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ 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
|
||||||
@@ -86,10 +84,9 @@ func checkProcessStopsAtDeadline(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
result, err := proc.Process(ctx, input, imagerequest.FormatJPEG,
|
result, err := proc.Process(ctx, input, smallJPEGTransform())
|
||||||
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)
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ import (
|
|||||||
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := New(Params{
|
_, err := New(Params{}).Process(
|
||||||
MaxInputBytes: 50 << 20,
|
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(
|
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
|
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
|
||||||
imagerequest.FormatJPEG, &imagerequest.Transform{},
|
&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)
|
||||||
|
|||||||
@@ -11,18 +11,14 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// processedSize runs input, an image in sourceFormat, through Process and
|
// processedSize runs input through Process and returns the output's size in
|
||||||
// returns the output's size in bytes.
|
// bytes.
|
||||||
func processedSize(
|
func processedSize(
|
||||||
t *testing.T, input []byte, sourceFormat imagerequest.Format,
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
transform *imagerequest.Transform,
|
|
||||||
) int64 {
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), transform)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -75,7 +71,7 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
size := processedSize(t, encodePNG(t, flat), imagerequest.FormatPNG,
|
size := processedSize(t, encodePNG(t, flat),
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
const rawBytes = width * height * 3
|
const rawBytes = width * height * 3
|
||||||
@@ -93,7 +89,7 @@ func TestImageProcessor_WebPAtEffort2(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input, imagerequest.FormatJPEG,
|
size := processedSize(t, input,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
@@ -135,7 +131,7 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
|||||||
|
|
||||||
input := encodePNG(t, source)
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
size := processedSize(t, input, imagerequest.FormatPNG,
|
size := processedSize(t, input,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
@@ -162,7 +158,7 @@ func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input, imagerequest.FormatJPEG,
|
size := processedSize(t, input,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
|
||||||
|
|
||||||
params := vips.NewJxlExportParams()
|
params := vips.NewJxlExportParams()
|
||||||
|
|||||||
@@ -36,11 +36,8 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
@@ -109,11 +106,8 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
|
|||||||
|
|
||||||
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatPNG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: imagerequest.FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
@@ -236,11 +230,8 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|||||||
|
|
||||||
input := createSquarePNG(t, tt.side, tt.squareSide)
|
input := createSquarePNG(t, tt.side, tt.squareSide)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatPNG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
|
|||||||
@@ -41,11 +41,8 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
@@ -98,11 +95,8 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
// Package imageprocessor provides image format conversion and resizing using libvips.
|
// Package imageprocessor provides image format conversion and resizing using libvips.
|
||||||
// It reads the size of an SVG source and renders it in child processes, each
|
|
||||||
// the running program started again (see RunAsSVGChildProcess). pixa kills
|
|
||||||
// them after SVGRenderTimeout, and each may use at most MaxSVGRenderMemory.
|
|
||||||
package imageprocessor
|
package imageprocessor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,12 +7,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,12 +71,18 @@ 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")
|
||||||
|
|
||||||
@@ -116,45 +118,11 @@ 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 its source
|
// libvips reads the input's first bytes as another format than the source
|
||||||
// format, or as none. An SVG whose first bytes pass the first-bytes check for
|
// formats, or as none: for example an SVG whose first bytes pass the
|
||||||
// AVIF is one example. A source format other than the seven pixa reads is
|
// first-bytes check for AVIF.
|
||||||
// also refused with it.
|
|
||||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||||
|
|
||||||
// ErrSVGNotOriginalSize is returned when orig is asked of an SVG source at a
|
|
||||||
// size other than orig. pixa returns an SVG only as it is; a raster format
|
|
||||||
// gives it a size.
|
|
||||||
var ErrSVGNotOriginalSize = errors.New(
|
|
||||||
"an SVG is returned as SVG only at the orig size")
|
|
||||||
|
|
||||||
// ErrSVGTooThin is returned, before rendering, when an SVG source has one
|
|
||||||
// side more than MaxInputDimension times the other. Rendered with its longer
|
|
||||||
// side at most MaxInputDimension, its shorter side would be under 1 pixel.
|
|
||||||
var ErrSVGTooThin = errors.New("SVG source too thin to render")
|
|
||||||
|
|
||||||
// ErrSVGTooLargeForOrigSize is returned, before rendering, when a format other
|
|
||||||
// than orig asks for an SVG source at the orig size and the SVG has a side
|
|
||||||
// above MaxOutputDimension.
|
|
||||||
var ErrSVGTooLargeForOrigSize = errors.New(
|
|
||||||
"SVG source too large to render at its own size")
|
|
||||||
|
|
||||||
// SVGRenderTimeout is how long the child processes that read the size of one
|
|
||||||
// SVG source and render it may run, counted from the start of the first,
|
|
||||||
// before pixa kills them.
|
|
||||||
const SVGRenderTimeout = 20 * time.Second
|
|
||||||
|
|
||||||
// MaxSVGRenderMemory is the most memory, in bytes, that each child process
|
|
||||||
// reading the size of an SVG source or rendering it may use. It is the
|
|
||||||
// child's RLIMIT_DATA, which limits its heap and its other private writable
|
|
||||||
// memory.
|
|
||||||
const MaxSVGRenderMemory = 1 << 30 // 1 GiB
|
|
||||||
|
|
||||||
// ErrSVGRenderFailed is returned when a child process fails to read the size
|
|
||||||
// of an SVG source or to render it: librsvg cannot read the SVG, or the
|
|
||||||
// child takes more than SVGRenderTimeout or MaxSVGRenderMemory.
|
|
||||||
var ErrSVGRenderFailed = errors.New("SVG source could not be rendered")
|
|
||||||
|
|
||||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
// processed and none finishes within ProcessingWaitTimeout.
|
// processed and none finishes within ProcessingWaitTimeout.
|
||||||
var ErrTooManyImages = errors.New("too many images being processed at once")
|
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||||
@@ -172,50 +140,52 @@ type ImageProcessor struct {
|
|||||||
processingSemaphore chan struct{}
|
processingSemaphore chan struct{}
|
||||||
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
||||||
processingWaitTimeout time.Duration
|
processingWaitTimeout time.Duration
|
||||||
// svgRenderTimeout is SVGRenderTimeout; tests shorten it.
|
|
||||||
svgRenderTimeout time.Duration
|
|
||||||
// maxSVGRenderMemory is MaxSVGRenderMemory; tests lower it.
|
|
||||||
maxSVGRenderMemory int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Params holds configuration for creating an ImageProcessor. Both fields
|
// Params holds configuration for creating an ImageProcessor.
|
||||||
// must be at least 1.
|
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
||||||
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: params.MaxInputBytes,
|
maxInputBytes: maxInputBytes,
|
||||||
processingSemaphore: make(chan struct{}, params.MaxConcurrentProcessing),
|
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
||||||
processingWaitTimeout: ProcessingWaitTimeout,
|
processingWaitTimeout: ProcessingWaitTimeout,
|
||||||
svgRenderTimeout: SVGRenderTimeout,
|
|
||||||
maxSVGRenderMemory: MaxSVGRenderMemory,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms the image read from input according to transform.
|
// Process transforms an image according to transform. When
|
||||||
// sourceFormat is the input's format, which magic detected from its first
|
// MaxConcurrentProcessing images are already being processed, it waits up
|
||||||
// bytes. When MaxConcurrentProcessing images are already being processed,
|
// to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
// 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
|
||||||
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
|
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
|
||||||
// refused with ErrAVIFOutputTooLarge before anything is decoded. orig of an
|
// refused with ErrAVIFOutputTooLarge before anything is decoded.
|
||||||
// SVG source is the SVG as it is, since libvips cannot save SVG; any other
|
|
||||||
// format renders it at the output size. Child processes read an SVG's size
|
|
||||||
// and render it, as processSVG describes.
|
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
sourceFormat imagerequest.Format,
|
|
||||||
transform *imagerequest.Transform,
|
transform *imagerequest.Transform,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
release, err := p.acquireSlot(ctx)
|
release, err := p.acquireSlot(ctx)
|
||||||
@@ -230,18 +200,39 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if sourceFormat == imagerequest.FormatSVG {
|
// govips picks the libvips loader from the input's first bytes with
|
||||||
return p.processSVG(ctx, data, transform)
|
// DetermineImageType. Input whose first bytes it reads as no source
|
||||||
}
|
// format, such as SVG, which librsvg would render, is refused before
|
||||||
|
// libvips reads it.
|
||||||
|
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
||||||
|
|
||||||
img, err := loadImage(data, sourceFormat)
|
sourceFormat, err := p.formatFromString(inputFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode image
|
||||||
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||||
|
}
|
||||||
defer img.Close()
|
defer img.Close()
|
||||||
|
|
||||||
origWidth, origHeight := img.Width(), img.Height()
|
// Turn the image upright now: encode strips the EXIF orientation tag,
|
||||||
outputFormat := resolveOutputFormat(transform.Format, sourceFormat)
|
// and sizes below must be worked out on the upright image.
|
||||||
|
err = img.AutoRotate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get original dimensions
|
||||||
|
origWidth := img.Width()
|
||||||
|
origHeight := img.Height()
|
||||||
|
|
||||||
|
// Validate input dimensions to prevent DoS via decompression bombs
|
||||||
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||||
|
return nil, ErrInputTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
// Determine target dimensions; too large ones are refused before resizing
|
// Determine target dimensions; too large ones are refused before resizing
|
||||||
targetWidth, targetHeight, err := targetDimensions(transform.Size,
|
targetWidth, targetHeight, err := targetDimensions(transform.Size,
|
||||||
@@ -250,6 +241,12 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orig is the source's own format; encode refuses an empty format
|
||||||
|
outputFormat := transform.Format
|
||||||
|
if outputFormat == imagerequest.FormatOriginal {
|
||||||
|
outputFormat = sourceFormat
|
||||||
|
}
|
||||||
|
|
||||||
// Determine output dimensions; too large ones are refused before
|
// 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,
|
||||||
@@ -266,104 +263,8 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.encodeResult(ctx, img, outputFormat, transform.Quality,
|
// Encode to target format
|
||||||
origWidth, origHeight)
|
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
|
||||||
}
|
|
||||||
|
|
||||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
|
||||||
// ends, and returns how many images were still being processed then. It
|
|
||||||
// waits by taking each slot in processingSemaphore as it frees up until it
|
|
||||||
// holds them all, or until ctx ends, then gives back the slots it took.
|
|
||||||
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
|
||||||
taken := 0
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
for range taken {
|
|
||||||
<-p.processingSemaphore
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for taken < cap(p.processingSemaphore) {
|
|
||||||
select {
|
|
||||||
case p.processingSemaphore <- struct{}{}:
|
|
||||||
taken++
|
|
||||||
case <-ctx.Done():
|
|
||||||
return len(p.processingSemaphore) - taken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// processSVG is Process for an SVG source, data. Apart from the first-bytes
|
|
||||||
// check, pixad hands the SVG to libvips only in child processes (see
|
|
||||||
// runSVGChildProcess). One reads the SVG's size. For a raster format a second
|
|
||||||
// renders it as PNG, which is then resized and encoded as any other image.
|
|
||||||
// pixa kills them once svgRenderTimeout has passed since the first one
|
|
||||||
// started.
|
|
||||||
func (p *ImageProcessor) processSVG(
|
|
||||||
ctx context.Context, data []byte, transform *imagerequest.Transform,
|
|
||||||
) (*Result, error) {
|
|
||||||
err := checkInputFormat(data, imagerequest.FormatSVG)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(p.svgRenderTimeout)
|
|
||||||
|
|
||||||
origWidth, origHeight, err := p.readSVGSize(ctx, deadline, data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
outputFormat := resolveOutputFormat(transform.Format, imagerequest.FormatSVG)
|
|
||||||
if outputFormat == imagerequest.FormatSVG {
|
|
||||||
return svgAsItIs(data, transform.Size, origWidth, origHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = checkSVGOrigSize(transform.Size, origWidth, origHeight)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
targetWidth, targetHeight, err := targetDimensions(transform.Size,
|
|
||||||
origWidth, origHeight)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
|
||||||
targetWidth, targetHeight, transform.FitMode, outputFormat)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The SVG is rendered at about the output size, to stay sharp
|
|
||||||
img, err := p.renderSVG(ctx, deadline, data, origWidth, origHeight,
|
|
||||||
outputWidth, outputHeight)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer img.Close()
|
|
||||||
|
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
|
||||||
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.encodeResult(ctx, img, outputFormat, transform.Quality,
|
|
||||||
origWidth, origHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeResult encodes img as format at quality and returns it as the result
|
|
||||||
// for a source of origWidth x origHeight.
|
|
||||||
func (p *ImageProcessor) encodeResult(
|
|
||||||
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
|
|
||||||
quality, origWidth, origHeight int,
|
|
||||||
) (*Result, error) {
|
|
||||||
output, err := p.encode(ctx, img, format, 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)
|
||||||
}
|
}
|
||||||
@@ -371,11 +272,12 @@ func (p *ImageProcessor) encodeResult(
|
|||||||
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: format.MIMEType(),
|
ContentType: FormatToMIME(outputFormat),
|
||||||
Width: img.Width(),
|
Width: img.Width(),
|
||||||
Height: img.Height(),
|
Height: img.Height(),
|
||||||
InputWidth: origWidth,
|
InputWidth: origWidth,
|
||||||
InputHeight: origHeight,
|
InputHeight: origHeight,
|
||||||
|
InputFormat: inputFormat,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,125 +312,85 @@ func targetDimensions(
|
|||||||
return width, height, nil
|
return width, height, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveOutputFormat returns the format of the output for the requested
|
// MIME types for the supported image formats.
|
||||||
// format: sourceFormat for orig, and format itself for any other. encode
|
const (
|
||||||
// refuses an empty format.
|
mimeJPEG = "image/jpeg"
|
||||||
func resolveOutputFormat(
|
mimePNG = "image/png"
|
||||||
format, sourceFormat imagerequest.Format,
|
mimeGIF = "image/gif"
|
||||||
) imagerequest.Format {
|
mimeWebP = "image/webp"
|
||||||
if format == imagerequest.FormatOriginal {
|
mimeAVIF = "image/avif"
|
||||||
return sourceFormat
|
mimeJXL = "image/jxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||||
|
return []string{
|
||||||
|
mimeJPEG,
|
||||||
|
mimePNG,
|
||||||
|
mimeGIF,
|
||||||
|
mimeWebP,
|
||||||
|
mimeAVIF,
|
||||||
|
mimeJXL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return format
|
// SupportedOutputFormats returns formats this processor can write.
|
||||||
|
func (p *ImageProcessor) SupportedOutputFormats() []imagerequest.Format {
|
||||||
|
return []imagerequest.Format{
|
||||||
|
imagerequest.FormatJPEG,
|
||||||
|
imagerequest.FormatPNG,
|
||||||
|
imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP,
|
||||||
|
imagerequest.FormatAVIF,
|
||||||
|
imagerequest.FormatJXL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadImage refuses data that libvips would not load as sourceFormat, then
|
// FormatToMIME converts a Format to its MIME type string.
|
||||||
// decodes it and turns the image upright: encode strips the EXIF orientation
|
func FormatToMIME(format imagerequest.Format) string {
|
||||||
// tag, and sizes must be worked out on the upright image. An image with a
|
switch format {
|
||||||
// side above MaxInputDimension is ErrInputTooLarge, which guards against
|
case imagerequest.FormatJPEG:
|
||||||
// decompression bombs.
|
return mimeJPEG
|
||||||
func loadImage(
|
case imagerequest.FormatPNG:
|
||||||
data []byte, sourceFormat imagerequest.Format,
|
return mimePNG
|
||||||
) (*vips.ImageRef, error) {
|
case imagerequest.FormatWebP:
|
||||||
err := checkInputFormat(data, sourceFormat)
|
return mimeWebP
|
||||||
if err != nil {
|
case imagerequest.FormatGIF:
|
||||||
return nil, err
|
return mimeGIF
|
||||||
|
case imagerequest.FormatAVIF:
|
||||||
|
return mimeAVIF
|
||||||
|
case imagerequest.FormatJXL:
|
||||||
|
return mimeJXL
|
||||||
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
|
return "application/octet-stream"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img, err := vips.NewImageFromBuffer(data)
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
if err != nil {
|
// ends, and returns how many images were still being processed then. It
|
||||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
// waits by taking each slot in processingSemaphore as it frees up until it
|
||||||
|
// holds them all, or until ctx ends, then gives back the slots it took.
|
||||||
|
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
||||||
|
taken := 0
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
for range taken {
|
||||||
|
<-p.processingSemaphore
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for taken < cap(p.processingSemaphore) {
|
||||||
|
select {
|
||||||
|
case p.processingSemaphore <- struct{}{}:
|
||||||
|
taken++
|
||||||
|
case <-ctx.Done():
|
||||||
|
return len(p.processingSemaphore) - taken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = img.AutoRotate()
|
return 0
|
||||||
if err != nil {
|
|
||||||
img.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if img.Width() > MaxInputDimension || img.Height() > MaxInputDimension {
|
|
||||||
img.Close()
|
|
||||||
|
|
||||||
return nil, ErrInputTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// svgAsItIs returns the SVG data unchanged, which is orig of an SVG source
|
|
||||||
// at the orig size. width x height is the SVG's own size. Any other size is
|
|
||||||
// ErrSVGNotOriginalSize.
|
|
||||||
func svgAsItIs(
|
|
||||||
data []byte, size imagerequest.Size, width, height int,
|
|
||||||
) (*Result, error) {
|
|
||||||
if !size.OriginalSize() {
|
|
||||||
return nil, fmt.Errorf("%w: asked for %dx%d", ErrSVGNotOriginalSize,
|
|
||||||
size.Width, size.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Result{
|
|
||||||
Content: io.NopCloser(bytes.NewReader(data)),
|
|
||||||
ContentLength: int64(len(data)),
|
|
||||||
ContentType: imagerequest.FormatSVG.MIMEType(),
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
InputWidth: width,
|
|
||||||
InputHeight: height,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkSVGOrigSize returns ErrSVGTooLargeForOrigSize when size is the orig
|
|
||||||
// size and the SVG source, of width x height, has a side above
|
|
||||||
// MaxOutputDimension. processSVG calls it after returning orig of the SVG, so
|
|
||||||
// the SVG would be rendered at its own size.
|
|
||||||
func checkSVGOrigSize(size imagerequest.Size, width, height int) error {
|
|
||||||
if size.OriginalSize() && max(width, height) > MaxOutputDimension {
|
|
||||||
return fmt.Errorf("%w: %dx%d is above %d", ErrSVGTooLargeForOrigSize,
|
|
||||||
width, height, MaxOutputDimension)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// svgRenderSize returns the least size in the proportions of width x height,
|
|
||||||
// an SVG's own size, that covers outputWidth x outputHeight, so that resize
|
|
||||||
// hardly changes it. Neither side is above MaxInputDimension.
|
|
||||||
func svgRenderSize(width, height, outputWidth, outputHeight int) (int, int) {
|
|
||||||
scale := min(
|
|
||||||
max(float64(outputWidth)/float64(width),
|
|
||||||
float64(outputHeight)/float64(height)),
|
|
||||||
float64(MaxInputDimension)/float64(max(width, height)))
|
|
||||||
|
|
||||||
return int(math.Round(float64(width) * scale)),
|
|
||||||
int(math.Round(float64(height) * scale))
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderSVG renders the SVG data, whose own size is width x height, at
|
|
||||||
// svgRenderSize for outputWidth x outputHeight, in a child process that pixa
|
|
||||||
// kills at deadline (see runSVGChildProcess). It returns the rendered image,
|
|
||||||
// read from the child's PNG. An SVG with one side more than
|
|
||||||
// MaxInputDimension times the other is ErrSVGTooThin.
|
|
||||||
func (p *ImageProcessor) renderSVG(
|
|
||||||
ctx context.Context, deadline time.Time, data []byte,
|
|
||||||
width, height, outputWidth, outputHeight int,
|
|
||||||
) (*vips.ImageRef, error) {
|
|
||||||
if max(width, height) > MaxInputDimension*min(width, height) {
|
|
||||||
return nil, fmt.Errorf("%w: %dx%d", ErrSVGTooThin, width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderWidth, renderHeight := svgRenderSize(width, height,
|
|
||||||
outputWidth, outputHeight)
|
|
||||||
|
|
||||||
png, err := p.runSVGChildProcess(ctx, deadline, data,
|
|
||||||
renderSVGAsPNGArgument, strconv.Itoa(renderWidth),
|
|
||||||
strconv.Itoa(renderHeight))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadImage(png, imagerequest.FormatPNG)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
||||||
@@ -574,51 +436,27 @@ func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkInputFormat returns ErrUnsupportedInputFormat unless libvips would
|
// detectFormat returns the format string for a govips image type.
|
||||||
// load data with the loader of sourceFormat. govips picks the libvips loader
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||||
// from the input's first bytes with DetermineImageType. It refuses an SVG
|
switch imageType {
|
||||||
// whose first bytes pass the first-bytes check for AVIF, which librsvg would
|
case vips.ImageTypeJPEG:
|
||||||
// otherwise render.
|
return "jpeg"
|
||||||
func checkInputFormat(data []byte, sourceFormat imagerequest.Format) error {
|
case vips.ImageTypePNG:
|
||||||
// A format with no loader, such as orig or auto, has the type govips also
|
return "png"
|
||||||
// gives input it cannot read, which the check below would let through.
|
case vips.ImageTypeGIF:
|
||||||
sourceType := vipsImageType(sourceFormat)
|
return "gif"
|
||||||
if sourceType == vips.ImageTypeUnknown {
|
case vips.ImageTypeWEBP:
|
||||||
return fmt.Errorf("%w: %q is not a source format",
|
return "webp"
|
||||||
ErrUnsupportedInputFormat, sourceFormat)
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
}
|
return string(imagerequest.FormatAVIF)
|
||||||
|
case vips.ImageTypeJXL:
|
||||||
if vips.DetermineImageType(data) != sourceType {
|
return string(imagerequest.FormatJXL)
|
||||||
return fmt.Errorf("%w: libvips does not read the input as %s",
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
ErrUnsupportedInputFormat, sourceFormat)
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
}
|
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.FormatSVG:
|
|
||||||
return vips.ImageTypeSVG
|
|
||||||
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
|
||||||
return vips.ImageTypeUnknown
|
|
||||||
default:
|
default:
|
||||||
return vips.ImageTypeUnknown
|
return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,9 +631,7 @@ func (p *ImageProcessor) encode(
|
|||||||
case imagerequest.FormatJXL:
|
case imagerequest.FormatJXL:
|
||||||
return exportJXL(img, quality)
|
return exportJXL(img, quality)
|
||||||
|
|
||||||
// libvips cannot save SVG; Process returns an SVG source as it is
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
case imagerequest.FormatSVG, imagerequest.FormatOriginal,
|
|
||||||
imagerequest.FormatAuto:
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -974,3 +810,26 @@ 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,
|
||||||
|
) (imagerequest.Format, error) {
|
||||||
|
switch format {
|
||||||
|
case "jpeg":
|
||||||
|
return imagerequest.FormatJPEG, nil
|
||||||
|
case "png":
|
||||||
|
return imagerequest.FormatPNG, nil
|
||||||
|
case "gif":
|
||||||
|
return imagerequest.FormatGIF, nil
|
||||||
|
case "webp":
|
||||||
|
return imagerequest.FormatWebP, nil
|
||||||
|
case string(imagerequest.FormatAVIF):
|
||||||
|
return imagerequest.FormatAVIF, nil
|
||||||
|
case string(imagerequest.FormatJXL):
|
||||||
|
return imagerequest.FormatJXL, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// The SVG tests start this test binary again as SVG child processes
|
|
||||||
if IsSVGChildProcess(os.Args[1:]) {
|
|
||||||
os.Exit(RunAsSVGChildProcess(os.Args[1:]))
|
|
||||||
}
|
|
||||||
|
|
||||||
initVips()
|
initVips()
|
||||||
|
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
@@ -99,23 +94,23 @@ func isAVIF(data []byte) bool {
|
|||||||
// 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 imagerequest.FormatJPEG.MIMEType()
|
return mimeJPEG
|
||||||
}
|
}
|
||||||
|
|
||||||
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 imagerequest.FormatPNG.MIMEType()
|
return mimePNG
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
return imagerequest.FormatGIF.MIMEType()
|
return mimeGIF
|
||||||
}
|
}
|
||||||
|
|
||||||
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 imagerequest.FormatWebP.MIMEType()
|
return mimeWebP
|
||||||
}
|
}
|
||||||
|
|
||||||
if isAVIF(data) {
|
if isAVIF(data) {
|
||||||
return imagerequest.FormatAVIF.MIMEType()
|
return mimeAVIF
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
@@ -124,7 +119,7 @@ func detectMIME(data []byte) string {
|
|||||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
@@ -136,8 +131,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
imagerequest.FormatJPEG, transform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +157,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != imagerequest.FormatJPEG.MIMEType() {
|
if mime != mimeJPEG {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +165,7 @@ 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{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
@@ -182,8 +176,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
imagerequest.FormatJPEG, transform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,7 +189,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != imagerequest.FormatPNG.MIMEType() {
|
if mime != mimePNG {
|
||||||
t.Errorf("Output format = %v, want image/png", mime)
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +201,7 @@ func processAndCheckSize(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, inputW, inputH)
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
@@ -220,8 +213,7 @@ func processAndCheckSize(
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
imagerequest.FormatJPEG, transform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +239,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
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
|
||||||
@@ -261,8 +253,7 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
imagerequest.FormatJPEG, transform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -294,7 +285,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
|||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestPNG(t, 400, 300)
|
input := createTestPNG(t, 400, 300)
|
||||||
@@ -305,8 +296,7 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
imagerequest.FormatPNG, transform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -322,6 +312,22 @@ 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()
|
||||||
|
|
||||||
@@ -340,7 +346,7 @@ 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{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
input := createTestJPEG(t, tt.width, tt.height)
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
|
|
||||||
@@ -351,8 +357,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input),
|
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -367,7 +372,7 @@ 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{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image at exactly MaxInputDimension - should be accepted
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
@@ -380,8 +385,7 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
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",
|
||||||
@@ -399,7 +403,7 @@ func encodeAndCheck(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
@@ -411,8 +415,7 @@ func encodeAndCheck(
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
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 +446,13 @@ func encodeAndCheck(
|
|||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, imagerequest.FormatWebP, 80, imagerequest.FormatWebP.MIMEType())
|
encodeAndCheck(t, imagerequest.FormatWebP, 80, mimeWebP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Load test AVIF file
|
// Load test AVIF file
|
||||||
@@ -466,8 +469,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -481,7 +483,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != imagerequest.FormatJPEG.MIMEType() {
|
if mime != mimeJPEG {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,7 +494,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, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create a valid JPEG that exceeds the byte limit
|
// Create a valid JPEG that exceeds the byte limit
|
||||||
@@ -508,8 +510,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input),
|
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -526,7 +527,7 @@ 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, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
transform := &imagerequest.Transform{
|
transform := &imagerequest.Transform{
|
||||||
@@ -536,8 +537,7 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input),
|
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -545,25 +545,37 @@ 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, imagerequest.FormatAVIF, 85, imagerequest.FormatAVIF.MIMEType())
|
encodeAndCheck(t, imagerequest.FormatAVIF, 85, mimeAVIF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndDecode runs input, a JPEG, through Process and decodes the output
|
// processAndDecode runs input through Process and decodes the output with
|
||||||
// with vips, so a test can inspect the image a client would receive.
|
// vips, so a test can inspect the image a client would receive.
|
||||||
func processAndDecode(
|
func processAndDecode(
|
||||||
t *testing.T, input []byte, transform *imagerequest.Transform,
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
) *vips.ImageRef {
|
) *vips.ImageRef {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(
|
||||||
MaxInputBytes: 50 << 20,
|
context.Background(), bytes.NewReader(input), transform,
|
||||||
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)
|
||||||
|
|||||||
@@ -21,12 +21,8 @@ func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
|||||||
Format: imagerequest.FormatJXL,
|
Format: imagerequest.FormatJXL,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{}).Process(
|
||||||
MaxInputBytes: 50 << 20,
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
|
||||||
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)
|
||||||
@@ -68,11 +64,8 @@ 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{
|
result, err := New(Params{}).Process(t.Context(),
|
||||||
MaxInputBytes: 50 << 20,
|
bytes.NewReader(input),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(t.Context(),
|
|
||||||
bytes.NewReader(input), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
|
&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)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -93,8 +94,7 @@ func processInBackground(
|
|||||||
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
) {
|
) {
|
||||||
go func() {
|
go func() {
|
||||||
result, err := proc.Process(context.Background(), reader,
|
result, err := proc.Process(context.Background(), reader, smallJPEGTransform())
|
||||||
imagerequest.FormatJPEG, smallJPEGTransform())
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = result.Content.Close()
|
_ = result.Content.Close()
|
||||||
}
|
}
|
||||||
@@ -117,15 +117,47 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewMakesMaxConcurrentProcessingSlots(t *testing.T) {
|
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 3})
|
for _, limit := range []int{0, -1} {
|
||||||
|
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
|
||||||
@@ -139,7 +171,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|||||||
calls = 6
|
calls = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: limit})
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
input := createTestJPEG(t, 50, 50)
|
input := createTestJPEG(t, 50, 50)
|
||||||
|
|
||||||
counter := &readingCounter{}
|
counter := &readingCounter{}
|
||||||
@@ -186,7 +218,7 @@ func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|||||||
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
input := createTestJPEG(t, 10, 10)
|
input := createTestJPEG(t, 10, 10)
|
||||||
@@ -207,7 +239,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
imagerequest.FormatJPEG, smallJPEGTransform())
|
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)
|
||||||
}
|
}
|
||||||
@@ -275,8 +307,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), tc.input,
|
_, err := proc.Process(context.Background(), tc.input, tc.transform)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -286,7 +317,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
imagerequest.FormatJPEG, smallJPEGTransform())
|
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)
|
||||||
}
|
}
|
||||||
@@ -303,7 +334,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
func TestWaitForProcessing(t *testing.T) {
|
func TestWaitForProcessing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 2})
|
proc := New(Params{MaxConcurrentProcessing: 2})
|
||||||
|
|
||||||
gate := make(chan struct{})
|
gate := make(chan struct{})
|
||||||
entered := make(chan struct{}, 1)
|
entered := make(chan struct{}, 1)
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||||
// input is refused with ErrUnsupportedInputFormat before it is decoded, and
|
// input, which is not a source format, is refused with
|
||||||
// not saved as JPEG. TIFF is not a source format, and the test passes the
|
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
|
||||||
// input to Process as a JPEG.
|
|
||||||
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -28,11 +27,8 @@ 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{
|
result, err := New(Params{}).Process(
|
||||||
MaxInputBytes: 50 << 20,
|
t.Context(), bytes.NewReader(tiff),
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
}).Process(
|
|
||||||
t.Context(), bytes.NewReader(tiff), imagerequest.FormatJPEG,
|
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
|
||||||
)
|
)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package imageprocessor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -15,14 +14,14 @@ import (
|
|||||||
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{})
|
||||||
|
|
||||||
for _, format := range []imagerequest.Format{
|
for _, format := range []imagerequest.Format{
|
||||||
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
} {
|
} {
|
||||||
saved, err := proc.Process(t.Context(),
|
saved, err := proc.Process(t.Context(),
|
||||||
bytes.NewReader(createTestPNG(t, 20, 20)), imagerequest.FormatPNG,
|
bytes.NewReader(createTestPNG(t, 20, 20)),
|
||||||
&imagerequest.Transform{Format: format})
|
&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)
|
||||||
@@ -33,7 +32,7 @@ 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), format,
|
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
||||||
&imagerequest.Transform{Format: imagerequest.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)
|
||||||
@@ -43,33 +42,9 @@ 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 != format.MIMEType() {
|
if result.ContentType != FormatToMIME(format) {
|
||||||
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, format.MIMEType())
|
format, result.ContentType, FormatToMIME(format))
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_NotASourceFormatRefused verifies that a source format
|
|
||||||
// other than the seven, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
package imageprocessor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An SVG child process is the running program started again with one of
|
|
||||||
// these arguments first, naming what it does. Its command line, after the
|
|
||||||
// program's name, is one of:
|
|
||||||
//
|
|
||||||
// read-svg-size MEMORY_LIMIT
|
|
||||||
// render-svg-as-png MEMORY_LIMIT WIDTH HEIGHT
|
|
||||||
//
|
|
||||||
// MEMORY_LIMIT is in bytes. The child reads the SVG on standard input. It
|
|
||||||
// writes the SVG's size as WIDTH HEIGHT, or the SVG rendered at WIDTH x
|
|
||||||
// HEIGHT as PNG, to standard output.
|
|
||||||
const (
|
|
||||||
readSVGSizeArgument = "read-svg-size"
|
|
||||||
renderSVGAsPNGArgument = "render-svg-as-png"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errSVGChildProcessArguments is returned for a command line that is not
|
|
||||||
// that of an SVG child process.
|
|
||||||
var errSVGChildProcessArguments = errors.New(
|
|
||||||
"not the command line of an SVG child process")
|
|
||||||
|
|
||||||
// IsSVGChildProcess reports whether args, a process's command line after the
|
|
||||||
// program's name, are those of an SVG child process.
|
|
||||||
func IsSVGChildProcess(args []string) bool {
|
|
||||||
return len(args) > 0 &&
|
|
||||||
(args[0] == readSVGSizeArgument || args[0] == renderSVGAsPNGArgument)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunAsSVGChildProcess runs this process as the SVG child process that args,
|
|
||||||
// its command line after the program's name, ask for. It returns the status
|
|
||||||
// to exit with: 0 once it has written its result, and 1 after writing an
|
|
||||||
// error to standard error, which the parent discards.
|
|
||||||
func RunAsSVGChildProcess(args []string) int {
|
|
||||||
err := runAsSVGChildProcess(args, os.Stdin, os.Stdout)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// runAsSVGChildProcess does the work of RunAsSVGChildProcess, reading the SVG
|
|
||||||
// from stdin and writing the result to stdout. It sets the process's limits
|
|
||||||
// before it reads the SVG.
|
|
||||||
func runAsSVGChildProcess(
|
|
||||||
args []string, stdin io.Reader, stdout io.Writer,
|
|
||||||
) error {
|
|
||||||
numbers, err := parseSVGChildProcessArguments(args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = setSVGChildProcessLimits(numbers[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
initVips()
|
|
||||||
|
|
||||||
svg, err := io.ReadAll(stdin)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read the SVG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if args[0] == readSVGSizeArgument {
|
|
||||||
return writeSVGSize(svg, stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeSVGAsPNG(svg, numbers[1], numbers[2], stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSVGChildProcessArguments returns the numbers on the command line of an
|
|
||||||
// SVG child process, args: the memory limit, then the width and height for
|
|
||||||
// render-svg-as-png.
|
|
||||||
func parseSVGChildProcessArguments(args []string) ([]int, error) {
|
|
||||||
wantArgs := 2
|
|
||||||
if len(args) > 0 && args[0] == renderSVGAsPNGArgument {
|
|
||||||
wantArgs = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
if !IsSVGChildProcess(args) || len(args) != wantArgs {
|
|
||||||
return nil, fmt.Errorf("%w: %q", errSVGChildProcessArguments, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
numbers := make([]int, 0, len(args)-1)
|
|
||||||
|
|
||||||
for _, arg := range args[1:] {
|
|
||||||
number, err := strconv.Atoi(arg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %q: %w", errSVGChildProcessArguments,
|
|
||||||
args, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
numbers = append(numbers, number)
|
|
||||||
}
|
|
||||||
|
|
||||||
return numbers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setSVGChildProcessLimits sets this process's RLIMIT_DATA to memoryLimit
|
|
||||||
// bytes, so that allocating heap or other private writable memory past it
|
|
||||||
// fails. RLIMIT_AS would also count the address space the Go runtime reserves
|
|
||||||
// but does not use. It also sets RLIMIT_CORE to 0: librsvg crashes when an
|
|
||||||
// allocation fails, and the crash would write a core file of up to
|
|
||||||
// memoryLimit bytes into pixad's working directory.
|
|
||||||
func setSVGChildProcessLimits(memoryLimit int) error {
|
|
||||||
if memoryLimit < 1 {
|
|
||||||
return fmt.Errorf("%w: memory limit %d", errSVGChildProcessArguments,
|
|
||||||
memoryLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := syscall.Setrlimit(syscall.RLIMIT_DATA, &syscall.Rlimit{
|
|
||||||
Cur: uint64(memoryLimit),
|
|
||||||
Max: uint64(memoryLimit),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to limit memory to %d bytes: %w",
|
|
||||||
memoryLimit, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = syscall.Setrlimit(syscall.RLIMIT_CORE, &syscall.Rlimit{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to turn off core files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSVGSize writes the size of svg to stdout as WIDTH HEIGHT. librsvg
|
|
||||||
// reads the whole SVG for it.
|
|
||||||
func writeSVGSize(svg []byte, stdout io.Writer) error {
|
|
||||||
img, err := vips.NewImageFromBuffer(svg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read the SVG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer img.Close()
|
|
||||||
|
|
||||||
_, err = fmt.Fprintf(stdout, "%d %d\n", img.Width(), img.Height())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSVGAsPNG renders svg at width x height and writes it to stdout as PNG.
|
|
||||||
// libvips' thumbnail has librsvg render the SVG at that size directly, at
|
|
||||||
// any scale: govips takes a density only in whole dots per inch, which cannot
|
|
||||||
// render an SVG below 1/72 of its own size. Given the SVG as bytes with no
|
|
||||||
// file name, librsvg loads no file or URL the SVG refers to.
|
|
||||||
func writeSVGAsPNG(svg []byte, width, height int, stdout io.Writer) error {
|
|
||||||
img, err := vips.NewThumbnailWithSizeFromBuffer(svg, width, height,
|
|
||||||
vips.InterestingNone, vips.SizeBoth)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to render the SVG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer img.Close()
|
|
||||||
|
|
||||||
png, err := exportPNG(img)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save the SVG as PNG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = stdout.Write(png)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readSVGSize reads the size of the SVG data in a child process that pixa
|
|
||||||
// kills at deadline (see runSVGChildProcess).
|
|
||||||
func (p *ImageProcessor) readSVGSize(
|
|
||||||
ctx context.Context, deadline time.Time, data []byte,
|
|
||||||
) (int, int, error) {
|
|
||||||
output, err := p.runSVGChildProcess(ctx, deadline, data,
|
|
||||||
readSVGSizeArgument)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var width, height int
|
|
||||||
|
|
||||||
_, err = fmt.Sscan(string(output), &width, &height)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("failed to read the size %q an SVG child "+
|
|
||||||
"process wrote: %w", output, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return width, height, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runSVGChildProcess starts the running program again as an SVG child
|
|
||||||
// process, with argument, p.maxSVGRenderMemory and extraArgs as its command
|
|
||||||
// line, and waits for it. It gives the child data on standard input,
|
|
||||||
// discards its standard error, and returns its standard output. pixa kills
|
|
||||||
// the child at deadline. A child that fails or is killed is
|
|
||||||
// ErrSVGRenderFailed, unless ctx has ended: runSVGChildProcess then returns
|
|
||||||
// ctx's error. Failing to start the child is another error.
|
|
||||||
func (p *ImageProcessor) runSVGChildProcess(
|
|
||||||
ctx context.Context, deadline time.Time, data []byte,
|
|
||||||
argument string, extraArgs ...string,
|
|
||||||
) ([]byte, error) {
|
|
||||||
executable, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to find the running program: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
childCtx, cancel := context.WithDeadline(ctx, deadline)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
args := append([]string{argument, strconv.Itoa(p.maxSVGRenderMemory)},
|
|
||||||
extraArgs...)
|
|
||||||
|
|
||||||
//nolint:gosec // the running program, with arguments pixa makes
|
|
||||||
cmd := exec.CommandContext(childCtx, executable, args...)
|
|
||||||
cmd.Stdin = bytes.NewReader(data)
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
|
|
||||||
err = cmd.Run()
|
|
||||||
|
|
||||||
var exitError *exec.ExitError
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
return stdout.Bytes(), nil
|
|
||||||
case ctx.Err() != nil:
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case childCtx.Err() != nil:
|
|
||||||
return nil, fmt.Errorf("%w: it took more than %v", ErrSVGRenderFailed,
|
|
||||||
p.svgRenderTimeout)
|
|
||||||
case errors.As(err, &exitError):
|
|
||||||
return nil, fmt.Errorf("%w: %w", ErrSVGRenderFailed, err)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("failed to run an SVG child process: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
package imageprocessor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testSVG is a 16x12 SVG, black on its left half and white on its right.
|
|
||||||
const testSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12">` +
|
|
||||||
`<rect width="16" height="12" fill="white"/>` +
|
|
||||||
`<rect width="8" height="12" fill="black"/></svg>`
|
|
||||||
|
|
||||||
// newSVGTestProcessor returns a processor for the SVG tests.
|
|
||||||
func newSVGTestProcessor() *ImageProcessor {
|
|
||||||
return New(Params{MaxInputBytes: 1 << 20, MaxConcurrentProcessing: 1})
|
|
||||||
}
|
|
||||||
|
|
||||||
// processSVG processes svg, as an SVG source, with transform.
|
|
||||||
func processSVG(
|
|
||||||
t *testing.T, svg string, transform imagerequest.Transform,
|
|
||||||
) (*Result, []byte, error) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
result, err := newSVGTestProcessor().Process(t.Context(),
|
|
||||||
bytes.NewReader([]byte(svg)), imagerequest.FormatSVG, &transform)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err := io.ReadAll(result.Content)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading the output: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_SVGOrigIsTheSVG checks that orig of an SVG at the orig
|
|
||||||
// size is the SVG itself, never another format, and that orig at another
|
|
||||||
// size is ErrSVGNotOriginalSize.
|
|
||||||
func TestImageProcessor_SVGOrigIsTheSVG(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
result, output, err := processSVG(t, testSVG,
|
|
||||||
imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("orig of the SVG: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("orig of the SVG: %s, %dx%d", result.ContentType, result.Width,
|
|
||||||
result.Height)
|
|
||||||
|
|
||||||
if result.ContentType != "image/svg+xml" || string(output) != testSVG {
|
|
||||||
t.Errorf("orig of the SVG = %s %q, want image/svg+xml %q",
|
|
||||||
result.ContentType, output, testSVG)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = processSVG(t, testSVG, imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 32, Height: 24},
|
|
||||||
Format: imagerequest.FormatOriginal,
|
|
||||||
})
|
|
||||||
if !errors.Is(err, ErrSVGNotOriginalSize) {
|
|
||||||
t.Errorf("orig of the SVG at 32x24: error = %v, want %v", err,
|
|
||||||
ErrSVGNotOriginalSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_RendersSVGToEachRasterFormat checks that an SVG is
|
|
||||||
// rendered to each raster format at the requested size.
|
|
||||||
func TestImageProcessor_RendersSVGToEachRasterFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, format := range []imagerequest.Format{
|
|
||||||
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
|
||||||
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
|
||||||
} {
|
|
||||||
result, output, err := processSVG(t, testSVG, imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 40, Height: 30},
|
|
||||||
Format: format,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("SVG as %s: %v", format, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("SVG as %s: %s, %dx%d, %d bytes", format, result.ContentType,
|
|
||||||
result.Width, result.Height, len(output))
|
|
||||||
|
|
||||||
if result.ContentType != format.MIMEType() ||
|
|
||||||
vips.DetermineImageType(output) != vipsImageType(format) ||
|
|
||||||
result.Width != 40 || result.Height != 30 {
|
|
||||||
t.Errorf("SVG as %s = %s, %dx%d; want %s, 40x30", format,
|
|
||||||
result.ContentType, result.Width, result.Height, format.MIMEType())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_RendersSVGSharpWhenEnlarged checks that a 16x12 SVG
|
|
||||||
// asked for at 1600x1200 is rendered at that size, not rendered at 16x12 and
|
|
||||||
// enlarged: 10 pixels either side of the edge between its black and white
|
|
||||||
// halves are black and white, where an enlarged image is grey there.
|
|
||||||
func TestImageProcessor_RendersSVGSharpWhenEnlarged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, output, err := processSVG(t, testSVG, imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 1600, Height: 1200},
|
|
||||||
Format: imagerequest.FormatPNG,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SVG as PNG at 1600x1200: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
img, err := vips.NewImageFromBuffer(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding the PNG: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(img.Close)
|
|
||||||
|
|
||||||
for x, want := range map[int]float64{790: 0, 810: 255} {
|
|
||||||
point, err := img.GetPoint(x, 600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetPoint(%d, 600): %v", x, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("pixel at %d,600: %v", x, point)
|
|
||||||
|
|
||||||
if point[0] != want {
|
|
||||||
t.Errorf("pixel at %d,600 = %v, want %v", x, point[0], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_SVGFetchesNothing checks that rendering an SVG draws no
|
|
||||||
// image it refers to: one at a URL on a local server, by href or xlink:href,
|
|
||||||
// or a local file that exists. Each SVG renders without error, white where
|
|
||||||
// the image would be. An SVG that uses an external entity at that server is
|
|
||||||
// a separate case. No SVG makes a request to the server.
|
|
||||||
func TestImageProcessor_SVGFetchesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var requests atomic.Int64
|
|
||||||
|
|
||||||
// Blue is 128 in every pixel of this PNG.
|
|
||||||
png := createTestPNG(t, 4, 4)
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requests.Add(1)
|
|
||||||
t.Logf("request for %s", r.URL)
|
|
||||||
w.Header().Set("Content-Type", "image/png")
|
|
||||||
_, _ = w.Write(png)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
pngPath := filepath.Join(t.TempDir(), "image.png")
|
|
||||||
|
|
||||||
err := os.WriteFile(pngPath, png, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing the PNG file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, reference := range []string{
|
|
||||||
`href="` + server.URL + `/image.png"`,
|
|
||||||
`xlink:href="` + server.URL + `/xlink.png"`,
|
|
||||||
`href="file://` + pngPath + `"`,
|
|
||||||
} {
|
|
||||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" ` +
|
|
||||||
`xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="12">` +
|
|
||||||
`<rect width="16" height="12" fill="white"/>` +
|
|
||||||
`<image ` + reference + ` width="16" height="12"/></svg>`
|
|
||||||
|
|
||||||
_, output, err := processSVG(t, svg, imagerequest.Transform{
|
|
||||||
Format: imagerequest.FormatPNG,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("SVG with image %s as PNG: %v", reference, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
img, err := vips.NewImageFromBuffer(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding the PNG: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
point, err := img.GetPoint(8, 6)
|
|
||||||
img.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetPoint(8, 6): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("SVG with image %s: pixel at 8,6 = %v", reference, point)
|
|
||||||
|
|
||||||
if point[2] != 255 {
|
|
||||||
t.Errorf("SVG with image %s: blue at 8,6 = %v, want 255: the "+
|
|
||||||
"image is drawn", reference, point[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg := `<?xml version="1.0"?>` +
|
|
||||||
`<!DOCTYPE svg [<!ENTITY outside SYSTEM "` + server.URL + `/entity">]>` +
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12">` +
|
|
||||||
`<text y="10">&outside;</text></svg>`
|
|
||||||
|
|
||||||
// librsvg loads no external entity, so it refuses this SVG: outside is
|
|
||||||
// not defined.
|
|
||||||
_, _, err = processSVG(t, svg, imagerequest.Transform{
|
|
||||||
Format: imagerequest.FormatPNG,
|
|
||||||
})
|
|
||||||
t.Logf("SVG with an external entity as PNG: error = %v", err)
|
|
||||||
|
|
||||||
if requests.Load() != 0 {
|
|
||||||
t.Errorf("rendering the SVGs made %d requests, want none",
|
|
||||||
requests.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_RendersSVGLargerThanMaxInputDimension checks that an
|
|
||||||
// SVG whose own size is above MaxInputDimension is limited only by the
|
|
||||||
// output checks: it is rendered at a raster size, orig of it is the SVG
|
|
||||||
// itself, and a raster format at its own size is ErrSVGTooLargeForOrigSize.
|
|
||||||
// The 1000000x1000000 one is rendered at a ten-thousandth of its own size.
|
|
||||||
func TestImageProcessor_RendersSVGLargerThanMaxInputDimension(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, svg := range []string{
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="10">` +
|
|
||||||
`<rect width="10000" height="10"/></svg>`,
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="1000000" ` +
|
|
||||||
`height="1000000"><rect width="1000000" height="1000000"/></svg>`,
|
|
||||||
} {
|
|
||||||
result, _, err := processSVG(t, svg, imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 100, Height: 100},
|
|
||||||
Format: imagerequest.FormatPNG,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s as PNG at 100x100: %v", svg, err)
|
|
||||||
} else {
|
|
||||||
t.Logf("%s as PNG at 100x100: %dx%d", svg, result.Width, result.Height)
|
|
||||||
|
|
||||||
if result.Width != 100 || result.Height != 100 {
|
|
||||||
t.Errorf("%s as PNG at 100x100 = %dx%d, want 100x100", svg,
|
|
||||||
result.Width, result.Height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, output, err := processSVG(t, svg,
|
|
||||||
imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
|
||||||
if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() ||
|
|
||||||
string(output) != svg {
|
|
||||||
t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = processSVG(t, svg,
|
|
||||||
imagerequest.Transform{Format: imagerequest.FormatPNG})
|
|
||||||
if !errors.Is(err, ErrSVGTooLargeForOrigSize) {
|
|
||||||
t.Errorf("%s as PNG at its own size: error = %v, want %v", svg,
|
|
||||||
err, ErrSVGTooLargeForOrigSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_RefusesSVGTooThin checks that an SVG with one side more
|
|
||||||
// than MaxInputDimension times the other is ErrSVGTooThin at a raster size,
|
|
||||||
// that one at exactly that proportion is rendered, and that orig of either
|
|
||||||
// is the SVG itself.
|
|
||||||
func TestImageProcessor_RefusesSVGTooThin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tooThin := `<svg xmlns="http://www.w3.org/2000/svg" width="81930" ` +
|
|
||||||
`height="10"><rect width="81930" height="10"/></svg>`
|
|
||||||
atTheLimit := `<svg xmlns="http://www.w3.org/2000/svg" width="81920" ` +
|
|
||||||
`height="10"><rect width="81920" height="10"/></svg>`
|
|
||||||
size := imagerequest.Size{Width: 100, Height: 100}
|
|
||||||
|
|
||||||
_, _, err := processSVG(t, tooThin,
|
|
||||||
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
|
|
||||||
if !errors.Is(err, ErrSVGTooThin) {
|
|
||||||
t.Errorf("81930x10 SVG as PNG at 100x100: error = %v, want %v", err,
|
|
||||||
ErrSVGTooThin)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, _, err := processSVG(t, atTheLimit,
|
|
||||||
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("81920x10 SVG as PNG at 100x100: %v", err)
|
|
||||||
} else if result.Width != 100 || result.Height != 100 {
|
|
||||||
t.Errorf("81920x10 SVG as PNG at 100x100 = %dx%d, want 100x100",
|
|
||||||
result.Width, result.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, svg := range []string{tooThin, atTheLimit} {
|
|
||||||
result, output, err := processSVG(t, svg,
|
|
||||||
imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
|
||||||
if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() ||
|
|
||||||
string(output) != svg {
|
|
||||||
t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testSVGRenderTimeout is the time limit of the SVG time limit tests, short
|
|
||||||
// so that they finish in a few seconds. Under the race detector a child
|
|
||||||
// process takes about a second to start and read an SVG's size.
|
|
||||||
const testSVGRenderTimeout = 3 * time.Second
|
|
||||||
|
|
||||||
// processSVGTimed has processor process svg, as an SVG source, with transform,
|
|
||||||
// and returns the error and how long it took.
|
|
||||||
func processSVGTimed(
|
|
||||||
t *testing.T, processor *ImageProcessor, svg string,
|
|
||||||
transform imagerequest.Transform,
|
|
||||||
) (time.Duration, error) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
_, err := processor.Process(t.Context(), strings.NewReader(svg),
|
|
||||||
imagerequest.FormatSVG, &transform)
|
|
||||||
|
|
||||||
return time.Since(start), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkProcessesAnotherImage checks that processor, after refusing an SVG,
|
|
||||||
// processes a PNG.
|
|
||||||
func checkProcessesAnotherImage(t *testing.T, processor *ImageProcessor) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, err := processor.Process(t.Context(),
|
|
||||||
bytes.NewReader(createTestPNG(t, 64, 48)), imagerequest.FormatPNG,
|
|
||||||
&imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 32, Height: 24},
|
|
||||||
Format: imagerequest.FormatJPEG,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("a PNG after the SVG: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_StopsSlowSVGRender checks that an SVG that takes about
|
|
||||||
// 12 seconds to render, 600 shapes each drawn with an feTurbulence filter
|
|
||||||
// effect, is ErrSVGRenderFailed soon after a time limit of
|
|
||||||
// testSVGRenderTimeout, not when the render would end. The processor then
|
|
||||||
// processes another image.
|
|
||||||
func TestImageProcessor_StopsSlowSVGRender(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
|
|
||||||
`<filter id="noise">` +
|
|
||||||
`<feTurbulence baseFrequency="0.05" numOctaves="4"/></filter>` +
|
|
||||||
strings.Repeat(`<rect width="256" height="256" filter="url(#noise)"/>`,
|
|
||||||
600) +
|
|
||||||
`</svg>`
|
|
||||||
|
|
||||||
processor := newSVGTestProcessor()
|
|
||||||
processor.svgRenderTimeout = testSVGRenderTimeout
|
|
||||||
|
|
||||||
elapsed, err := processSVGTimed(t, processor, svg, imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 256, Height: 256},
|
|
||||||
Format: imagerequest.FormatPNG,
|
|
||||||
})
|
|
||||||
t.Logf("slow SVG as PNG: error %v after %v", err, elapsed)
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrSVGRenderFailed) {
|
|
||||||
t.Errorf("slow SVG as PNG: error = %v, want %v", err,
|
|
||||||
ErrSVGRenderFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed > testSVGRenderTimeout+2*time.Second {
|
|
||||||
t.Errorf("slow SVG as PNG: refused after %v, want soon after %v",
|
|
||||||
elapsed, testSVGRenderTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
checkProcessesAnotherImage(t, processor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_StopsSlowSVGSizeRead checks that orig of an SVG whose
|
|
||||||
// size takes about 10 seconds to read is ErrSVGRenderFailed soon after a time
|
|
||||||
// limit of testSVGRenderTimeout. To read the size, librsvg matches each of the
|
|
||||||
// SVG's 2500 CSS rules against each of its 2500 shapes, walking up the 75
|
|
||||||
// groups around it. The processor then processes another image.
|
|
||||||
func TestImageProcessor_StopsSlowSVGSizeRead(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var rules strings.Builder
|
|
||||||
|
|
||||||
for i := range 2500 {
|
|
||||||
fmt.Fprintf(&rules, ".group%d rect { fill: red }", i)
|
|
||||||
}
|
|
||||||
|
|
||||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12">` +
|
|
||||||
`<style>` + rules.String() + `</style>` + strings.Repeat(`<g>`, 75) +
|
|
||||||
strings.Repeat(`<rect width="1" height="1"/>`, 2500) +
|
|
||||||
strings.Repeat(`</g>`, 75) + `</svg>`
|
|
||||||
|
|
||||||
processor := newSVGTestProcessor()
|
|
||||||
processor.svgRenderTimeout = testSVGRenderTimeout
|
|
||||||
|
|
||||||
elapsed, err := processSVGTimed(t, processor, svg,
|
|
||||||
imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
|
||||||
t.Logf("orig of the SVG: error %v after %v", err, elapsed)
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrSVGRenderFailed) {
|
|
||||||
t.Errorf("orig of the SVG: error = %v, want %v", err,
|
|
||||||
ErrSVGRenderFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed > testSVGRenderTimeout+2*time.Second {
|
|
||||||
t.Errorf("orig of the SVG: refused after %v, want soon after %v",
|
|
||||||
elapsed, testSVGRenderTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
checkProcessesAnotherImage(t, processor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageProcessor_StopsSVGRenderOverMemoryLimit checks that an SVG that
|
|
||||||
// needs about 500 MiB more than a child process's own memory to render, a
|
|
||||||
// chain of 2000 blurs that each keep their result, is ErrSVGRenderFailed with
|
|
||||||
// a memory limit of 512 MiB, and that a plain SVG renders under the same
|
|
||||||
// limit. A child process needs about 130 MiB of its own, and about 350 MiB
|
|
||||||
// under the race detector, so the chain also renders under MaxSVGRenderMemory.
|
|
||||||
func TestImageProcessor_StopsSVGRenderOverMemoryLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var blurs strings.Builder
|
|
||||||
|
|
||||||
blurs.WriteString(`<feGaussianBlur stdDeviation="3" result="blur0"/>`)
|
|
||||||
|
|
||||||
for i := 1; i < 2000; i++ {
|
|
||||||
fmt.Fprintf(&blurs,
|
|
||||||
`<feGaussianBlur in="blur%d" stdDeviation="3" result="blur%d"/>`,
|
|
||||||
i-1, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
|
|
||||||
`<filter id="blurs" color-interpolation-filters="sRGB">` +
|
|
||||||
blurs.String() + `</filter>` +
|
|
||||||
`<rect width="256" height="256" fill="red" filter="url(#blurs)"/></svg>`
|
|
||||||
size := imagerequest.Size{Width: 256, Height: 256}
|
|
||||||
|
|
||||||
processor := newSVGTestProcessor()
|
|
||||||
processor.maxSVGRenderMemory = 512 << 20
|
|
||||||
|
|
||||||
elapsed, err := processSVGTimed(t, processor, svg,
|
|
||||||
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
|
|
||||||
t.Logf("SVG of 2000 blurs as PNG: error %v after %v", err, elapsed)
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrSVGRenderFailed) {
|
|
||||||
t.Errorf("SVG of 2000 blurs as PNG: error = %v, want %v", err,
|
|
||||||
ErrSVGRenderFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = processSVGTimed(t, processor, testSVG,
|
|
||||||
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("plain SVG as PNG after it: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSVGRenderSize checks the size at which an SVG is rendered to cover an
|
|
||||||
// output size, and that it is held to MaxInputDimension.
|
|
||||||
func TestSVGRenderSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
width, height int
|
|
||||||
outputWidth, outputHeight int
|
|
||||||
wantWidth, wantHeight int
|
|
||||||
}{
|
|
||||||
{"own size", 16, 12, 16, 12, 16, 12},
|
|
||||||
{"100 times larger", 16, 12, 1600, 1200, 1600, 1200},
|
|
||||||
{"wider than the SVG's proportions", 16, 12, 160, 12, 160, 120},
|
|
||||||
{"10 times smaller", 100, 100, 10, 10, 10, 10},
|
|
||||||
{"held to MaxInputDimension", 8192, 1, 8192, 8192, 8192, 1},
|
|
||||||
{"held to MaxInputDimension when enlarged", 1024, 1, 8192, 8192, 8192, 8},
|
|
||||||
{"far above MaxInputDimension", 1000000, 1000000, 100, 100, 100, 100},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
gotWidth, gotHeight := svgRenderSize(tt.width, tt.height,
|
|
||||||
tt.outputWidth, tt.outputHeight)
|
|
||||||
if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight {
|
|
||||||
t.Errorf("%s: svgRenderSize(%d, %d, %d, %d) = %dx%d, want %dx%d",
|
|
||||||
tt.name, tt.width, tt.height, tt.outputWidth, tt.outputHeight,
|
|
||||||
gotWidth, gotHeight, tt.wantWidth, tt.wantHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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,933 +0,0 @@
|
|||||||
// 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 metrics of upstream fetches and transforms, made and registered by
|
|
||||||
// New. They are kept in memory, so updating one waits for nothing.
|
|
||||||
upstreamFetches prometheus.Counter
|
|
||||||
upstreamFetchBytes prometheus.Counter
|
|
||||||
upstreamFetchDurations prometheus.Histogram
|
|
||||||
upstreamFetchErrors prometheus.Counter
|
|
||||||
transforms prometheus.Counter
|
|
||||||
transformDurations prometheus.Histogram
|
|
||||||
transformsInProgress prometheus.Gauge
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 metrics on
|
|
||||||
// metricsRegistry. pixad passes Prometheus' default registry, the one
|
|
||||||
// /metrics serves. It fails when metricsRegistry already holds metrics 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.",
|
|
||||||
}),
|
|
||||||
upstreamFetchDurations: prometheus.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "pixa_upstream_fetch_duration_seconds",
|
|
||||||
Help: "Duration of the fetches pixa_upstream_fetches_total counts, " +
|
|
||||||
"until the response's last byte.",
|
|
||||||
}),
|
|
||||||
upstreamFetchErrors: prometheus.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "pixa_upstream_fetch_errors_total",
|
|
||||||
Help: "Fetches of a source image from upstream that failed, " +
|
|
||||||
"before or while reading the response.",
|
|
||||||
}),
|
|
||||||
transforms: prometheus.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "pixa_transforms_total",
|
|
||||||
Help: "Images the image processor transformed.",
|
|
||||||
}),
|
|
||||||
transformDurations: prometheus.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "pixa_transform_duration_seconds",
|
|
||||||
Help: "Duration of the transforms pixa_transforms_total counts, " +
|
|
||||||
"waiting for a processing slot included.",
|
|
||||||
}),
|
|
||||||
transformsInProgress: prometheus.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "pixa_transforms_in_progress",
|
|
||||||
Help: "Images being transformed or waiting for a processing slot.",
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, metric := range []prometheus.Collector{
|
|
||||||
p.upstreamFetches, p.upstreamFetchBytes, p.upstreamFetchDurations,
|
|
||||||
p.upstreamFetchErrors, p.transforms, p.transformDurations,
|
|
||||||
p.transformsInProgress,
|
|
||||||
} {
|
|
||||||
err = metricsRegistry.Register(metric)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to register the proxy's metrics: %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. It counts a
|
|
||||||
// source cache miss when it fetches the source because the source has no row
|
|
||||||
// in the cache or its cached file cannot be used, and a source cache hit
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.cache.IncrementSourceStats(false)
|
|
||||||
|
|
||||||
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:
|
|
||||||
p.cache.IncrementSourceStats(true)
|
|
||||||
|
|
||||||
return p.processFetched(ctx, req, cacheKey, fetched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
variant := p.renewVariant(ctx, cacheKey, cached)
|
|
||||||
if variant != nil {
|
|
||||||
p.cache.IncrementSourceStats(true)
|
|
||||||
|
|
||||||
return variant, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
source, sourceSize, sourceFormat := p.loadCachedSource(ctx, req.Source)
|
|
||||||
p.cache.IncrementSourceStats(source != nil)
|
|
||||||
|
|
||||||
// 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 and its duration, including
|
|
||||||
// when reading the response or a later step fails, and counts a fetch that
|
|
||||||
// fails or whose response cannot be read as an upstream fetch error. 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) {
|
|
||||||
err := p.refuseNegativeCached(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch from upstream
|
|
||||||
p.log.Debug("fetching from upstream", "host", req.Source.Host,
|
|
||||||
"path", req.Source.Path, "query", req.Source.Query)
|
|
||||||
|
|
||||||
fetchStart := time.Now()
|
|
||||||
|
|
||||||
fetchResult, err := p.fetcher.Fetch(ctx, req.Source,
|
|
||||||
ifNoneMatch, ifModifiedSince)
|
|
||||||
if err != nil {
|
|
||||||
p.upstreamFetchErrors.Inc()
|
|
||||||
|
|
||||||
// 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, time.Since(fetchStart))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
p.upstreamFetchErrors.Inc()
|
|
||||||
|
|
||||||
_ = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// refuseNegativeCached returns an error wrapping ErrNegativeCached while
|
|
||||||
// req's source URL is in the negative cache, and nil otherwise. A failure to
|
|
||||||
// read the negative cache is only logged, and returns nil.
|
|
||||||
func (p *Proxy) refuseNegativeCached(
|
|
||||||
ctx context.Context, req *imagerequest.Request,
|
|
||||||
) error {
|
|
||||||
negHit, err := p.cache.Failure(ctx, req.Source)
|
|
||||||
if err != nil {
|
|
||||||
p.log.Warn("negative cache check failed", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !negHit {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
p.log.Debug("negative cache hit",
|
|
||||||
"host", req.Source.Host,
|
|
||||||
"path", req.Source.Path,
|
|
||||||
)
|
|
||||||
|
|
||||||
return fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// and took duration. A fetch that read no bytes is not counted.
|
|
||||||
func (p *Proxy) incrementUpstreamFetch(fetchBytes int64, duration time.Duration) {
|
|
||||||
if fetchBytes <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.upstreamFetches.Inc()
|
|
||||||
p.upstreamFetchBytes.Add(float64(fetchBytes))
|
|
||||||
p.upstreamFetchDurations.Observe(duration.Seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.transform(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()
|
|
||||||
p.transformDurations.Observe(processDuration.Seconds())
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// transform has the image processor transform the image read from source
|
|
||||||
// as requested says, and counts it in pixa_transforms_in_progress until the
|
|
||||||
// processor returns or panics.
|
|
||||||
func (p *Proxy) transform(
|
|
||||||
ctx context.Context,
|
|
||||||
source io.Reader,
|
|
||||||
sourceFormat imagerequest.Format,
|
|
||||||
requested *imagerequest.Transform,
|
|
||||||
) (*imageprocessor.Result, error) {
|
|
||||||
p.transformsInProgress.Inc()
|
|
||||||
defer p.transformsInProgress.Dec()
|
|
||||||
|
|
||||||
return p.processor.Process(ctx, source, sourceFormat, requested)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
package imageproxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// metricValues holds the source cache, upstream fetch and transform metrics
|
|
||||||
// of a proxy and its cache. A histogram is given by its count of
|
|
||||||
// observations.
|
|
||||||
type metricValues struct {
|
|
||||||
sourceCacheHits uint64
|
|
||||||
sourceCacheMisses uint64
|
|
||||||
fetchDurations uint64
|
|
||||||
fetchErrors uint64
|
|
||||||
transformDuration uint64
|
|
||||||
inProgress float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// readMetricValues reads the metrics of svc and its cache that
|
|
||||||
// metricValues holds from their metrics registry.
|
|
||||||
func readMetricValues(t *testing.T, svc *testProxy) metricValues {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
families, err := svc.registry.Gather()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to gather the metrics: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var values metricValues
|
|
||||||
|
|
||||||
for _, family := range families {
|
|
||||||
metric := family.GetMetric()[0]
|
|
||||||
|
|
||||||
switch family.GetName() {
|
|
||||||
case "pixa_source_cache_hits_total":
|
|
||||||
values.sourceCacheHits = uint64(metric.GetCounter().GetValue())
|
|
||||||
case "pixa_source_cache_misses_total":
|
|
||||||
values.sourceCacheMisses = uint64(metric.GetCounter().GetValue())
|
|
||||||
case "pixa_upstream_fetch_duration_seconds":
|
|
||||||
values.fetchDurations = metric.GetHistogram().GetSampleCount()
|
|
||||||
case "pixa_upstream_fetch_errors_total":
|
|
||||||
values.fetchErrors = uint64(metric.GetCounter().GetValue())
|
|
||||||
case "pixa_transform_duration_seconds":
|
|
||||||
values.transformDuration = metric.GetHistogram().GetSampleCount()
|
|
||||||
case "pixa_transforms_in_progress":
|
|
||||||
values.inProgress = metric.GetGauge().GetValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProxy_Get_CountsSourceCacheFetchesAndTransforms walks Get through a
|
|
||||||
// miss that fetches the source, a hit, a miss that uses the cached source
|
|
||||||
// and a miss whose source is not found upstream, checking the source cache,
|
|
||||||
// upstream fetch and transform metrics after each.
|
|
||||||
func TestProxy_Get_CountsSourceCacheFetchesAndTransforms(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := setupTestProxy(t)
|
|
||||||
|
|
||||||
// want is source cache hits, source cache misses, fetch durations
|
|
||||||
// observed, fetch errors, transform durations observed, transforms in
|
|
||||||
// progress.
|
|
||||||
steps := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
size int
|
|
||||||
wantErr bool
|
|
||||||
want metricValues
|
|
||||||
}{
|
|
||||||
{"miss that fetches the source", testPathPhoto, 50, false,
|
|
||||||
metricValues{0, 1, 1, 0, 1, 0}},
|
|
||||||
{"hit", testPathPhoto, 50, false,
|
|
||||||
metricValues{0, 1, 1, 0, 1, 0}},
|
|
||||||
{"miss that uses the cached source", testPathPhoto, 25, false,
|
|
||||||
metricValues{1, 1, 1, 0, 2, 0}},
|
|
||||||
{"miss whose source is not found", "/images/absent.jpg", 50, true,
|
|
||||||
metricValues{1, 2, 1, 1, 2, 0}},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := readMetricValues(t, svc)
|
|
||||||
t.Logf("after the %s: metrics = %+v", step.name, got)
|
|
||||||
|
|
||||||
if got != step.want {
|
|
||||||
t.Fatalf("after the %s: metrics = %+v, want %+v", step.name, got, step.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProxy_Get_CountsMissingSourceFileAsSourceCacheMiss deletes the file of
|
|
||||||
// a cached source but keeps its row. A request for a second variant of that
|
|
||||||
// source must fetch the source again, and count a source cache miss, not a
|
|
||||||
// hit.
|
|
||||||
func TestProxy_Get_CountsMissingSourceFileAsSourceCacheMiss(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := setupTestProxy(t)
|
|
||||||
|
|
||||||
// A first request caches the photo as a source.
|
|
||||||
resp, err := svc.Get(t.Context(), widthOnlyRequest(fixtures, 50))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Get() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
|
|
||||||
source := widthOnlyRequest(fixtures, 50).Source
|
|
||||||
|
|
||||||
err = os.Remove(sourceFilePath(svc, source.Key()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to delete the cached source file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
before := readMetricValues(t, svc)
|
|
||||||
|
|
||||||
resp, err = svc.Get(t.Context(), widthOnlyRequest(fixtures, 25))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Get() of a second variant error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Content.Close()
|
|
||||||
|
|
||||||
after := readMetricValues(t, svc)
|
|
||||||
t.Logf("before the second variant: metrics = %+v; after: %+v", before, after)
|
|
||||||
|
|
||||||
if after.sourceCacheHits != before.sourceCacheHits ||
|
|
||||||
after.sourceCacheMisses != before.sourceCacheMisses+1 {
|
|
||||||
t.Errorf("source cache hits went from %d to %d and misses from %d to %d, "+
|
|
||||||
"want no new hit and one more miss", before.sourceCacheHits,
|
|
||||||
after.sourceCacheHits, before.sourceCacheMisses, after.sourceCacheMisses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProxy_Get_CountsTransformWaitingForSlotAsInProgress holds the only
|
|
||||||
// processing slot and starts a request that must transform the photo. While
|
|
||||||
// the request waits for the slot, pixa_transforms_in_progress must be 1; once
|
|
||||||
// the request returns, 0.
|
|
||||||
func TestProxy_Get_CountsTransformWaitingForSlotAsInProgress(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc, fixtures := setupTestProxy(t)
|
|
||||||
svc.processor = imageprocessor.New(
|
|
||||||
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
|
||||||
)
|
|
||||||
|
|
||||||
release := holdProcessingSlot(t, svc.processor)
|
|
||||||
|
|
||||||
result := startGet(t.Context(), svc, widthOnlyRequest(fixtures, 50))
|
|
||||||
|
|
||||||
// The request fetches the photo first, then waits for the slot.
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for readMetricValues(t, svc).inProgress != 1 && time.Now().Before(deadline) {
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
waiting := readMetricValues(t, svc).inProgress
|
|
||||||
t.Logf("while the request waits for the slot: transforms in progress = %v",
|
|
||||||
waiting)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case got := <-result:
|
|
||||||
t.Fatalf("Get() returned while the only processing slot was held, "+
|
|
||||||
"error = %v", got.err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if waiting != 1 {
|
|
||||||
t.Fatalf("transforms in progress = %v while the request waits for the "+
|
|
||||||
"slot, want 1", waiting)
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
|
|
||||||
got := <-result
|
|
||||||
if got.err != nil {
|
|
||||||
t.Fatalf("Get() error = %v", got.err)
|
|
||||||
}
|
|
||||||
|
|
||||||
returned := readMetricValues(t, svc).inProgress
|
|
||||||
t.Logf("once the request returned: transforms in progress = %v", returned)
|
|
||||||
|
|
||||||
if returned != 0 {
|
|
||||||
t.Errorf("transforms in progress = %v once the request returned, want 0",
|
|
||||||
returned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
// Package imagerequest holds the image request type, its parts, its checks,
|
// 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
|
// the parsing of the /v1/image/ path, the variant key and the source key. It
|
||||||
// source's upstream URL. It also holds the image format type and the MIME type
|
// imports no other package of this repo, so every package that handles an
|
||||||
// of each format. It imports no other package of this repo, so every package
|
// image request can import it.
|
||||||
// that handles an image request can import it.
|
|
||||||
package imagerequest
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
// Format is an image format. pixa reads and writes the formats from JPEG to
|
// Format represents supported output image formats.
|
||||||
// GIF below. It reads SVG and writes it only as orig of an SVG source. In a
|
|
||||||
// request, orig and auto stand for another format.
|
|
||||||
type Format string
|
type Format string
|
||||||
|
|
||||||
// Supported image formats.
|
// Supported image output formats.
|
||||||
const (
|
const (
|
||||||
FormatOriginal Format = "orig"
|
FormatOriginal Format = "orig"
|
||||||
FormatJPEG Format = "jpeg"
|
FormatJPEG Format = "jpeg"
|
||||||
@@ -27,7 +25,6 @@ const (
|
|||||||
FormatAVIF Format = "avif"
|
FormatAVIF Format = "avif"
|
||||||
FormatJXL Format = "jxl"
|
FormatJXL Format = "jxl"
|
||||||
FormatGIF Format = "gif"
|
FormatGIF Format = "gif"
|
||||||
FormatSVG Format = "svg"
|
|
||||||
|
|
||||||
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
|
// 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
|
// request from its Accept header once the URL's signature or token has
|
||||||
@@ -35,32 +32,6 @@ const (
|
|||||||
FormatAuto Format = "auto"
|
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 FormatSVG:
|
|
||||||
return "image/svg+xml"
|
|
||||||
case FormatOriginal, FormatAuto:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size represents requested image dimensions
|
// Size represents requested image dimensions
|
||||||
type Size struct {
|
type Size struct {
|
||||||
Width int
|
Width int
|
||||||
@@ -114,13 +85,19 @@ func ValidateImageRequest(req *Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request represents a request for a processed image: the source to fetch
|
// 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
|
// and the transform to apply to it.
|
||||||
// covers.
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// Source is what to fetch
|
// Source is what to fetch
|
||||||
Source Source
|
Source Source
|
||||||
// Transform is what to do to it
|
// Transform is what to do to it
|
||||||
Transform Transform
|
Transform Transform
|
||||||
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
|
Signature string
|
||||||
|
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
||||||
|
// of an encrypted URL; the zero time if it has none
|
||||||
|
Expires time.Time
|
||||||
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
|
AllowHTTP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source is the image a request fetches from the origin.
|
// Source is the image a request fetches from the origin.
|
||||||
@@ -148,20 +125,23 @@ type Transform struct {
|
|||||||
FitMode Fit
|
FitMode Fit
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL returns the source's upstream URL, with the scheme http when allowHTTP
|
// SourceURL returns the full upstream URL to fetch: the source path
|
||||||
// is true and https otherwise. net/url writes its path percent-encoded again,
|
// percent-encoded again, as net/url writes a path, so each source path is one
|
||||||
// so each source path is one upstream path. Its query is the source query as
|
// upstream path, and the source query as it is, with the characters
|
||||||
// it is, with the characters escapeQuery encodes percent-encoded.
|
// escapeQuery encodes percent-encoded. Uses http:// scheme when AllowHTTP is
|
||||||
func (s Source) URL(allowHTTP bool) *url.URL {
|
// true, otherwise https://.
|
||||||
|
func (r *Request) SourceURL() string {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if allowHTTP {
|
if r.AllowHTTP {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &url.URL{
|
sourceURL := url.URL{
|
||||||
Scheme: scheme,
|
Scheme: scheme,
|
||||||
Host: s.Host,
|
Host: r.Source.Host,
|
||||||
Path: s.Path,
|
Path: r.Source.Path,
|
||||||
RawQuery: escapeQuery(s.Query),
|
RawQuery: escapeQuery(r.Source.Query),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sourceURL.String()
|
||||||
}
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,49 +2,57 @@ package imagerequest
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestSource_URL_DefaultHTTPS(t *testing.T) {
|
func TestRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
source := Source{
|
req := &Request{
|
||||||
|
Source: Source{
|
||||||
Host: testHostCDN,
|
Host: testHostCDN,
|
||||||
Path: testPathCat,
|
Path: testPathCat,
|
||||||
Query: "v=2",
|
Query: "v=2",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := source.URL(false).String()
|
got := req.SourceURL()
|
||||||
|
|
||||||
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("URL() = %q, want %q", got, want)
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSource_URL_AllowHTTP(t *testing.T) {
|
func TestRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
source := Source{
|
req := &Request{
|
||||||
|
Source: Source{
|
||||||
Host: "localhost:8080",
|
Host: "localhost:8080",
|
||||||
Path: testPathCat,
|
Path: testPathCat,
|
||||||
|
},
|
||||||
|
AllowHTTP: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
got := source.URL(true).String()
|
got := req.SourceURL()
|
||||||
|
|
||||||
want := "http://localhost:8080/photos/cat.jpg"
|
want := "http://localhost:8080/photos/cat.jpg"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("URL() = %q, want %q", got, want)
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSource_URL_AllowHTTPFalse(t *testing.T) {
|
func TestRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
source := Source{
|
req := &Request{
|
||||||
|
Source: Source{
|
||||||
Host: testHostCDN,
|
Host: testHostCDN,
|
||||||
Path: "/img.jpg",
|
Path: "/img.jpg",
|
||||||
|
},
|
||||||
|
AllowHTTP: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
got := source.URL(false).String()
|
got := req.SourceURL()
|
||||||
if got != "https://cdn.example.com/img.jpg" {
|
if got != "https://cdn.example.com/img.jpg" {
|
||||||
t.Errorf("URL() = %q, want https scheme", got)
|
t.Errorf("SourceURL() = %q, want https scheme", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,23 @@ 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]*)?$`)
|
||||||
|
|
||||||
|
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
|
||||||
|
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
|
||||||
|
// Examples:
|
||||||
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
|
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||||
|
// - cdn.example.com/photos/cat.jpg/orig.png
|
||||||
|
// - cdn.example.com/photos/cat.jpg/800x600
|
||||||
|
func ParseImagePath(path string) (*Request, error) {
|
||||||
|
// Strip leading slash if present (chi may include it)
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return nil, ErrMissingHost
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseImageComponents(path)
|
||||||
|
}
|
||||||
|
|
||||||
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
// 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
|
||||||
@@ -126,15 +143,15 @@ func parseImageComponents(remainder string) (*Request, error) {
|
|||||||
return nil, ErrMissingHost
|
return nil, ErrMissingHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Later steps put the host unparsed into the allowlist check, the cache key
|
// Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
|
||||||
// and the upstream URL, so only the forms hostRegex describes are accepted
|
// the host would make Go's URL parser, which the allowlist check and the
|
||||||
|
// fetcher use, read a different host from the one allowlisted and cached
|
||||||
hostMatch := hostRegex.FindStringSubmatch(host)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The allowlist check and the fetcher drop the brackets, so brackets
|
// Go's URL parser refuses brackets around anything but an IPv6 address
|
||||||
// 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() {
|
||||||
@@ -147,8 +164,9 @@ func parseImageComponents(remainder string) (*Request, error) {
|
|||||||
// host
|
// host
|
||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
// A "#" starts a URL's fragment, so neither the source path nor the source
|
// Go's URL parser, which the fetcher uses, reads a "#" as the start of a
|
||||||
// query may hold one
|
// fragment and drops the rest, so each text after it would be another
|
||||||
|
// spelling, cached under its own keys, of one upstream request
|
||||||
if strings.Contains(path, "#") {
|
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)
|
||||||
|
|||||||
@@ -192,16 +192,76 @@ func TestParseImageURL_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseImageURL_LowerCasesHost verifies that the host is lower-cased, so
|
func TestParseImagePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *Request
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "chi wildcard capture",
|
||||||
|
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: testPathCat,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with leading slash from chi",
|
||||||
|
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
want: &Request{
|
||||||
|
Source: Source{
|
||||||
|
Host: testHostCDN,
|
||||||
|
Path: testPathCat,
|
||||||
|
},
|
||||||
|
Transform: Transform{
|
||||||
|
Size: Size{Width: 800, Height: 600},
|
||||||
|
Format: FormatWebP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImagePath(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRequest(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
||||||
// that every spelling of its letter case is the same host to the allowlist,
|
// 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 TestParseImageURL_LowerCasesHost(t *testing.T) {
|
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got, err := ParseImageURL("/v1/image/CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseImageURL() error = %v", err)
|
t.Fatalf("ParseImagePath() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
||||||
@@ -215,21 +275,21 @@ func TestParseImageURL_LowerCasesHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseImageURL_RefusesUserinfo verifies that a host holding "@" is
|
// TestParseImagePath_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 TestParseImageURL_RefusesUserinfo(t *testing.T) {
|
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, input := range []string{
|
for _, input := range []string{
|
||||||
"/v1/image/user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
"/v1/image/user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
} {
|
} {
|
||||||
got, err := ParseImageURL(input)
|
got, err := ParseImagePath(input)
|
||||||
t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
|
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
||||||
|
|
||||||
if !errors.Is(err, ErrInvalidHost) {
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
t.Errorf("ParseImageURL(%q) error = %v, want %v",
|
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
||||||
input, err, ErrInvalidHost)
|
input, err, ErrInvalidHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,10 +315,6 @@ 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",
|
||||||
@@ -301,6 +357,40 @@ 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
|
||||||
|
|||||||
+300
-519
@@ -1,14 +1,3 @@
|
|||||||
// Package imgcache is the image cache. It keeps:
|
|
||||||
// - the source images fetched from upstream
|
|
||||||
// - the variants made from them
|
|
||||||
// - the negative cache: the source URLs whose fetch failed recently
|
|
||||||
//
|
|
||||||
// Each image is a file with a row in the database. A source is used, and its
|
|
||||||
// variants are served, until the cache TTL has passed since upstream last sent
|
|
||||||
// the source or confirmed it unchanged. Then the source is due to be checked
|
|
||||||
// with upstream again (see Stale). Eviction removes:
|
|
||||||
// - the least recently used images, to keep the cache under its size limit
|
|
||||||
// - the images not checked with upstream for EvictionAfterLastCheck
|
|
||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,15 +6,19 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/magic"
|
)
|
||||||
|
|
||||||
|
// Cache errors.
|
||||||
|
var (
|
||||||
|
ErrCacheMiss = errors.New("cache miss")
|
||||||
|
ErrNegativeCache = errors.New("negative cache hit")
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxPendingAccessTimes is how many access times of variants served from
|
// maxPendingAccessTimes is how many access times of variants served from
|
||||||
@@ -40,42 +33,29 @@ const maxPendingAccessTimes = 10000
|
|||||||
// have expired.
|
// have expired.
|
||||||
const cleanExpiredBatchSize = 1000
|
const cleanExpiredBatchSize = 1000
|
||||||
|
|
||||||
// DefaultNegativeTTL is how long pixad keeps a failed fetch in the negative
|
// CacheConfig holds cache configuration.
|
||||||
// cache.
|
type CacheConfig struct {
|
||||||
const DefaultNegativeTTL = 5 * time.Minute
|
|
||||||
|
|
||||||
// DefaultCacheTTL is how long after upstream last sent a cached source, or
|
|
||||||
// answered that it had not changed, pixad uses the source and serves its
|
|
||||||
// variants without asking upstream again.
|
|
||||||
const DefaultCacheTTL = 24 * time.Hour
|
|
||||||
|
|
||||||
// EvictionAfterLastCheck is how long after upstream last sent a cached
|
|
||||||
// source, or answered that it had not changed, the source and its variants
|
|
||||||
// are evicted (see EvictNotCheckedRecently). Until then a source whose checks
|
|
||||||
// fail is used.
|
|
||||||
const EvictionAfterLastCheck = 30 * 24 * time.Hour
|
|
||||||
|
|
||||||
// ErrStale is returned by Variant for a variant whose file's modification
|
|
||||||
// time is CacheTTL or more ago (see Variant).
|
|
||||||
var ErrStale = errors.New("cached variant is older than the cache TTL")
|
|
||||||
|
|
||||||
// Config holds cache configuration.
|
|
||||||
type Config struct {
|
|
||||||
StateDir string
|
StateDir string
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
|
|
||||||
// CacheTTL is how long a cached source is used, and its variants served,
|
|
||||||
// before upstream is asked again whether the source changed. Zero means
|
|
||||||
// DefaultCacheTTL.
|
|
||||||
CacheTTL time.Duration
|
|
||||||
|
|
||||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
// enforces. Zero turns the disk cache off entirely: no cache
|
// enforces. Zero means no limit is enforced (no eviction).
|
||||||
// directories are created, lookups always miss, stores are no-ops,
|
|
||||||
// and the eviction goroutine only removes the expired negative cache
|
|
||||||
// rows.
|
|
||||||
MaxBytes int64
|
MaxBytes int64
|
||||||
|
|
||||||
|
// UseDefaultMaxBytes makes NewCache replace MaxBytes with the
|
||||||
|
// default limit: 75% of the sum of the space free on the filesystem
|
||||||
|
// holding the cache and the bytes the cache already holds, at least
|
||||||
|
// DefaultCacheMaxBytesFloor. The config layer sets this when the
|
||||||
|
// operator omits cache_max_bytes.
|
||||||
|
UseDefaultMaxBytes bool
|
||||||
|
|
||||||
|
// DisableDiskCache turns the disk cache off entirely: no cache
|
||||||
|
// directories are created, lookups always miss, stores are
|
||||||
|
// no-ops, and the eviction goroutine only removes the expired
|
||||||
|
// negative cache rows. The config layer sets this when the
|
||||||
|
// operator configures cache_max_bytes: 0.
|
||||||
|
DisableDiskCache bool
|
||||||
|
|
||||||
// Logger receives accounting and eviction log output. A nil
|
// Logger receives accounting and eviction log output. A nil
|
||||||
// Logger means slog.Default().
|
// Logger means slog.Default().
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
@@ -84,11 +64,15 @@ type Config struct {
|
|||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
srcContent *FileStore // source images by source key
|
srcContent *ContentStorage // source images by source key
|
||||||
variants *FileStore // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
config Config
|
config CacheConfig
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
|
// disabled means the disk cache is turned off entirely: lookups
|
||||||
|
// always miss, stores are no-ops, and no eviction runs.
|
||||||
|
disabled bool
|
||||||
|
|
||||||
// Eviction machinery. The channels are created in NewCache so
|
// Eviction machinery. The channels are created in NewCache so
|
||||||
// stores can signal write pressure without racing StartEviction.
|
// stores can signal write pressure without racing StartEviction.
|
||||||
// evictionCancel, set by StartEviction, cancels the eviction
|
// evictionCancel, set by StartEviction, cancels the eviction
|
||||||
@@ -97,33 +81,33 @@ type Cache struct {
|
|||||||
evictionDone chan struct{}
|
evictionDone chan struct{}
|
||||||
evictionCancel context.CancelFunc
|
evictionCancel context.CancelFunc
|
||||||
|
|
||||||
// pendingVariantAccessTimes holds the variants served from the cache,
|
// The pending counts: hits, misses, upstream fetches and transforms
|
||||||
// and pendingSourceAccessTimes the cached sources a miss used, whose
|
// that requests counted, kept here until they are written by the
|
||||||
// access time is still to be written by the goroutine that
|
// goroutine that StartPendingCountWrites starts, so that no request
|
||||||
// StartPendingAccessTimeWrites starts, so that no request waits for the
|
// waits for the database to count it. pendingVariantAccessTimes holds
|
||||||
// database to record it. As for eviction, the channels are created in
|
// the variants served from the cache, and pendingSourceAccessTimes the
|
||||||
|
// cached sources a miss used, whose access time is still to be written
|
||||||
|
// by that goroutine. As for eviction, the channels are created in
|
||||||
// NewCache, so that requests can hand over access times before that
|
// NewCache, so that requests can hand over access times before that
|
||||||
// goroutine starts; pendingAccessTimesDone is closed when it returns.
|
// goroutine starts; pendingCountsDone is closed when it returns.
|
||||||
// pendingAccessTimesCancel, set by StartPendingAccessTimeWrites,
|
// pendingCountsCancel, set by StartPendingCountWrites, cancels its
|
||||||
// cancels its context, which tells it to finish.
|
// context, which tells it to finish.
|
||||||
|
pendingHits atomic.Int64
|
||||||
|
pendingMisses atomic.Int64
|
||||||
|
pendingUpstreamFetches atomic.Int64
|
||||||
|
pendingUpstreamFetchBytes atomic.Int64
|
||||||
|
pendingTransforms atomic.Int64
|
||||||
pendingVariantAccessTimes chan imagerequest.VariantKey
|
pendingVariantAccessTimes chan imagerequest.VariantKey
|
||||||
pendingSourceAccessTimes chan imagerequest.SourceKey
|
pendingSourceAccessTimes chan imagerequest.SourceKey
|
||||||
pendingAccessTimesDone chan struct{}
|
pendingCountsDone chan struct{}
|
||||||
pendingAccessTimesCancel context.CancelFunc
|
pendingCountsCancel context.CancelFunc
|
||||||
|
|
||||||
// The cache's metrics, made and registered by registerMetrics (see
|
// Held through each write of the pending counts, and by
|
||||||
// metrics.go)
|
// readHitAndMissCounts to read them
|
||||||
cacheHits prometheus.Counter
|
pendingCountsWriteMutex sync.Mutex
|
||||||
cacheMisses prometheus.Counter
|
|
||||||
sourceCacheHits prometheus.Counter
|
|
||||||
sourceCacheMisses prometheus.Counter
|
|
||||||
usageBytes prometheus.Gauge
|
|
||||||
maxBytes prometheus.Gauge
|
|
||||||
evictions prometheus.Counter
|
|
||||||
evictedBytes prometheus.Counter
|
|
||||||
|
|
||||||
// reconciliationPageSize is the most rows one read of a content table
|
// reconciliationPageSize is the most rows one read of a content table
|
||||||
// returns in the reconciliation pass. NewCache sets it to
|
// returns in the reconciliation pass and in Stats. newCache sets it to
|
||||||
// defaultReconciliationPageSize; tests set it smaller.
|
// defaultReconciliationPageSize; tests set it smaller.
|
||||||
reconciliationPageSize int
|
reconciliationPageSize int
|
||||||
|
|
||||||
@@ -134,52 +118,48 @@ type Cache struct {
|
|||||||
reconciliationReadTestHook func(rows int)
|
reconciliationReadTestHook func(rows int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a new cache instance and registers its metrics on
|
// NewCache creates a new cache instance.
|
||||||
// metricsRegistry. pixad passes Prometheus' default registry, the one
|
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||||
// /metrics serves. It makes no database call.
|
return newCache(db, config, defaultFreeSpaceProbe)
|
||||||
func NewCache(
|
}
|
||||||
db *sql.DB, config Config, metricsRegistry prometheus.Registerer,
|
|
||||||
|
// newCache is NewCache with the free-space probe passed in, so tests
|
||||||
|
// can fake the free space the default limit is worked out from.
|
||||||
|
func newCache(
|
||||||
|
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
|
||||||
) (*Cache, error) {
|
) (*Cache, error) {
|
||||||
log := config.Logger
|
log := config.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.CacheTTL == 0 {
|
|
||||||
config.CacheTTL = DefaultCacheTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &Cache{
|
c := &Cache{
|
||||||
db: db,
|
db: db,
|
||||||
config: config,
|
config: config,
|
||||||
log: log,
|
log: log,
|
||||||
|
disabled: config.DisableDiskCache,
|
||||||
evictionPressure: make(chan struct{}, 1),
|
evictionPressure: make(chan struct{}, 1),
|
||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
|
|
||||||
pendingVariantAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
|
pendingVariantAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
|
||||||
pendingSourceAccessTimes: make(chan imagerequest.SourceKey, maxPendingAccessTimes),
|
pendingSourceAccessTimes: make(chan imagerequest.SourceKey, maxPendingAccessTimes),
|
||||||
pendingAccessTimesDone: make(chan struct{}),
|
pendingCountsDone: make(chan struct{}),
|
||||||
|
|
||||||
reconciliationPageSize: defaultReconciliationPageSize,
|
reconciliationPageSize: defaultReconciliationPageSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.registerMetrics(metricsRegistry)
|
if c.disabled {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.MaxBytes == 0 {
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
srcContent, err := NewFileStore(
|
srcContent, err := NewContentStorage(
|
||||||
filepath.Join(config.StateDir, "cache", "sources"),
|
filepath.Join(config.StateDir, "cache", "sources"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewFileStore(
|
variants, err := NewVariantStorage(
|
||||||
filepath.Join(config.StateDir, "cache", "variants"),
|
filepath.Join(config.StateDir, "cache", "variants"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,367 +169,50 @@ func NewCache(
|
|||||||
c.srcContent = srcContent
|
c.srcContent = srcContent
|
||||||
c.variants = variants
|
c.variants = variants
|
||||||
|
|
||||||
|
if config.UseDefaultMaxBytes {
|
||||||
|
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config.MaxBytes = limit
|
||||||
|
|
||||||
|
log.Info("computed default cache size limit from free space and cache contents",
|
||||||
|
"cache_max_bytes", limit,
|
||||||
|
"cache_dir", filepath.Join(config.StateDir, "cache"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VariantFile is the open file of a cached variant, as Variant,
|
// GetVariant opens a cached variant and returns a reader, its size and its
|
||||||
// VariantIgnoringTTL and RenewVariant return it.
|
// content type, read from the variant file's first bytes. It opens that one
|
||||||
type VariantFile struct {
|
// file and makes no database call. A variant not in the cache, or any
|
||||||
// Content reads the file. The caller closes it.
|
// variant of a disabled cache, is ErrNotFound; a variant file whose first
|
||||||
Content io.ReadCloser
|
|
||||||
// Size is the file's size in bytes.
|
|
||||||
Size int64
|
|
||||||
// ContentType is the MIME type of the variant's format, read from the
|
|
||||||
// file's first bytes.
|
|
||||||
ContentType string
|
|
||||||
// CheckedAt is the file's modification time: its source's check time
|
|
||||||
// when the variant was made or last renewed.
|
|
||||||
CheckedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variant opens a cached variant and returns its file (see VariantFile). It
|
|
||||||
// opens that one file and makes no database call: the file's modification
|
|
||||||
// time is its source's check time when the variant was made or last renewed
|
|
||||||
// (see PutVariant), and a variant whose time is CacheTTL or more ago is
|
|
||||||
// ErrStale. A variant it opens has its access time handed to the background
|
|
||||||
// writer without waiting (see touchVariant). A variant not in the cache, or
|
|
||||||
// any variant of a disabled cache, is ErrNotFound; a variant file whose first
|
|
||||||
// bytes are not those of an output format is another error.
|
// bytes are not those of an output format is another error.
|
||||||
func (c *Cache) Variant(cacheKey imagerequest.VariantKey) (VariantFile, error) {
|
func (c *Cache) GetVariant(
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return VariantFile{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
file, info, err := c.variants.Open(string(cacheKey))
|
|
||||||
if err != nil {
|
|
||||||
return VariantFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Stale(info.ModTime()) {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return VariantFile{}, ErrStale
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.finishOpeningVariant(cacheKey, file, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VariantIgnoringTTL opens a cached variant whatever its age, and returns it
|
|
||||||
// as Variant does. The image proxy reads it for a variant whose source is not
|
|
||||||
// cached, before it checks the source with upstream (see LookupVariant).
|
|
||||||
func (c *Cache) VariantIgnoringTTL(
|
|
||||||
cacheKey imagerequest.VariantKey,
|
cacheKey imagerequest.VariantKey,
|
||||||
) (VariantFile, error) {
|
) (io.ReadCloser, int64, string, error) {
|
||||||
if c.config.MaxBytes == 0 {
|
if c.disabled {
|
||||||
return VariantFile{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
file, info, err := c.variants.Open(string(cacheKey))
|
|
||||||
if err != nil {
|
|
||||||
return VariantFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.finishOpeningVariant(cacheKey, file, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenewVariant opens a cached variant made from the source bytes that source
|
|
||||||
// describes, whatever its age, and returns it as Variant does. It moves the
|
|
||||||
// variant's check time forward to source.CheckedAt (see SetVariantCheckedAt).
|
|
||||||
// A variant made before source.FetchedAt was made from older bytes, and is
|
|
||||||
// ErrNotFound, as is a variant not in the cache.
|
|
||||||
func (c *Cache) RenewVariant(
|
|
||||||
ctx context.Context, cacheKey imagerequest.VariantKey, source SourceInfo,
|
|
||||||
) (VariantFile, error) {
|
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return VariantFile{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
file, info, err := c.variants.Open(string(cacheKey))
|
|
||||||
if err != nil {
|
|
||||||
return VariantFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.ModTime().Before(source.FetchedAt) {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return VariantFile{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
format, err := readFormat(file)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return VariantFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
checkedAt := info.ModTime()
|
|
||||||
|
|
||||||
if checkedAt.Before(source.CheckedAt) {
|
|
||||||
err = c.SetVariantCheckedAt(ctx, cacheKey, source.CheckedAt)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return VariantFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
checkedAt = source.CheckedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
c.touchVariant(cacheKey)
|
|
||||||
|
|
||||||
return VariantFile{
|
|
||||||
Content: file,
|
|
||||||
Size: info.Size(),
|
|
||||||
ContentType: format.MIMEType(),
|
|
||||||
CheckedAt: checkedAt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetVariantCheckedAt records checkedAt as when upstream last sent the source
|
|
||||||
// bytes the cached variant of cacheKey was made from, or answered that they
|
|
||||||
// had not changed. It sets the variant file's modification time to checkedAt,
|
|
||||||
// so Variant serves the file until CacheTTL after it. It sets the checked_at
|
|
||||||
// of the variant's row too, so eviction keeps the variant until
|
|
||||||
// EvictionAfterLastCheck after it. A failure to write the row is only logged.
|
|
||||||
// On a disabled cache it is a no-op.
|
|
||||||
func (c *Cache) SetVariantCheckedAt(
|
|
||||||
ctx context.Context, cacheKey imagerequest.VariantKey, checkedAt time.Time,
|
|
||||||
) error {
|
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := c.variants.SetModTime(string(cacheKey), checkedAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx,
|
|
||||||
`UPDATE variant_content SET checked_at = ? WHERE cache_key = ?`,
|
|
||||||
checkedAt.UTC(), string(cacheKey))
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to renew variant's checked_at",
|
|
||||||
"cache_key", cacheKey, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale reports whether a source last checked with upstream at checkedAt,
|
|
||||||
// and its variants, are due to be checked again.
|
|
||||||
func (c *Cache) Stale(checkedAt time.Time) bool {
|
|
||||||
return time.Since(checkedAt) >= c.config.CacheTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutVariant stores a processed variant by its cache key and records it in
|
|
||||||
// the size accounting. source describes the source bytes the variant was made
|
|
||||||
// from. Its CheckedAt becomes the modification time of the variant's file,
|
|
||||||
// which Variant and RenewVariant read. The variant's row keeps its CheckedAt,
|
|
||||||
// ETag and LastModified (see LookupVariant). On a disabled cache it is a
|
|
||||||
// no-op. The accounting insert is best-effort (the startup reconciliation
|
|
||||||
// pass adopts any variant file that misses its accounting row).
|
|
||||||
func (c *Cache) PutVariant(
|
|
||||||
ctx context.Context,
|
|
||||||
cacheKey imagerequest.VariantKey,
|
|
||||||
source SourceInfo,
|
|
||||||
content io.Reader,
|
|
||||||
) error {
|
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(content)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.variants.Write(string(cacheKey), data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file left with the time of its writing could be served as made from
|
|
||||||
// bytes fetched after the ones it was made from
|
|
||||||
err = c.variants.SetModTime(string(cacheKey), source.CheckedAt)
|
|
||||||
if err != nil {
|
|
||||||
deleteErr := c.variants.Delete(string(cacheKey))
|
|
||||||
if deleteErr != nil {
|
|
||||||
c.log.Warn("failed to remove variant file whose time was not set",
|
|
||||||
"cache_key", cacheKey, "error", deleteErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
size := int64(len(data))
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO variant_content (cache_key, size_bytes, checked_at,
|
|
||||||
upstream_etag, upstream_last_modified)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET
|
|
||||||
size_bytes = excluded.size_bytes,
|
|
||||||
last_accessed_at = CURRENT_TIMESTAMP,
|
|
||||||
checked_at = excluded.checked_at,
|
|
||||||
upstream_etag = excluded.upstream_etag,
|
|
||||||
upstream_last_modified = excluded.upstream_last_modified
|
|
||||||
`, string(cacheKey), size, source.CheckedAt.UTC(), source.ETag,
|
|
||||||
source.LastModified)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to record variant in size accounting",
|
|
||||||
"cache_key", cacheKey, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.notifyWritePressure()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source opens the cached source of src and returns a reader, its size in
|
|
||||||
// bytes and its format, read from the file's first bytes. A source whose row
|
|
||||||
// or file is missing is ErrNotFound, as is any source of a disabled cache; a
|
|
||||||
// file whose first bytes are not those of a source format is another error.
|
|
||||||
// A source it opens has its access time handed to the background writer
|
|
||||||
// without waiting (see touchSourceContent).
|
|
||||||
func (c *Cache) Source(
|
|
||||||
ctx context.Context, src imagerequest.Source,
|
|
||||||
) (io.ReadCloser, int64, imagerequest.Format, error) {
|
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return nil, 0, "", ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
key := src.Key()
|
return c.variants.LoadWithContentType(cacheKey)
|
||||||
|
|
||||||
var rowExists bool
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
|
|
||||||
`, string(key)).Scan(&rowExists)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, "", fmt.Errorf("failed to lookup source: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rowExists {
|
// StoreSource stores fetched source content: it writes the file named by
|
||||||
return nil, 0, "", ErrNotFound
|
// the source's key, then the source's row. When the row cannot be written
|
||||||
}
|
// the file is removed and the store fails, as a file with no row is never
|
||||||
|
// served or evicted; if that removal fails too, the next reconciliation
|
||||||
file, info, err := c.srcContent.Open(string(key))
|
// pass removes the file. On a disabled cache it is a no-op returning an
|
||||||
if err != nil {
|
// empty key.
|
||||||
return nil, 0, "", err
|
func (c *Cache) StoreSource(
|
||||||
}
|
|
||||||
|
|
||||||
format, err := readFormat(file)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.touchSourceContent(key)
|
|
||||||
|
|
||||||
return file, info.Size(), format, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceInfo is what the cache keeps in a cached source's row besides its
|
|
||||||
// size and access time.
|
|
||||||
type SourceInfo struct {
|
|
||||||
// FetchedAt is when the cached bytes were fetched.
|
|
||||||
FetchedAt time.Time
|
|
||||||
// CheckedAt is when upstream last sent the bytes or answered that they
|
|
||||||
// had not changed.
|
|
||||||
CheckedAt time.Time
|
|
||||||
// ETag is the ETag header upstream sent with the bytes, or "".
|
|
||||||
ETag string
|
|
||||||
// LastModified is the Last-Modified header upstream sent with the
|
|
||||||
// bytes, or "".
|
|
||||||
LastModified string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupSource returns what the cache keeps about the cached source of src.
|
|
||||||
// A source with no row is ErrNotFound, as is any source of a disabled cache.
|
|
||||||
func (c *Cache) LookupSource(
|
|
||||||
ctx context.Context, src imagerequest.Source,
|
|
||||||
) (SourceInfo, error) {
|
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return SourceInfo{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var info SourceInfo
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT fetched_at, checked_at, upstream_etag, upstream_last_modified
|
|
||||||
FROM source_content WHERE source_key = ?
|
|
||||||
`, string(src.Key())).Scan(
|
|
||||||
&info.FetchedAt, &info.CheckedAt, &info.ETag, &info.LastModified)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return SourceInfo{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return SourceInfo{}, fmt.Errorf("failed to look up source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupVariant returns what the row of the cached variant of cacheKey keeps
|
|
||||||
// about the source bytes the variant was made from: the CheckedAt, ETag and
|
|
||||||
// LastModified they had when the variant was made or last renewed. The row
|
|
||||||
// keeps no FetchedAt, so it is zero. With the ETag and LastModified the image
|
|
||||||
// proxy checks the variant with upstream after its source is evicted. A
|
|
||||||
// variant with no row is ErrNotFound, as is any variant of a disabled cache.
|
|
||||||
func (c *Cache) LookupVariant(
|
|
||||||
ctx context.Context, cacheKey imagerequest.VariantKey,
|
|
||||||
) (SourceInfo, error) {
|
|
||||||
if c.config.MaxBytes == 0 {
|
|
||||||
return SourceInfo{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var info SourceInfo
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT checked_at, upstream_etag, upstream_last_modified
|
|
||||||
FROM variant_content WHERE cache_key = ?
|
|
||||||
`, string(cacheKey)).Scan(&info.CheckedAt, &info.ETag, &info.LastModified)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return SourceInfo{}, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return SourceInfo{}, fmt.Errorf("failed to look up variant: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenewSource records that upstream answered at checkedAt that the cached
|
|
||||||
// source of src had not changed.
|
|
||||||
func (c *Cache) RenewSource(
|
|
||||||
ctx context.Context, src imagerequest.Source, checkedAt time.Time,
|
|
||||||
) error {
|
|
||||||
_, err := c.db.ExecContext(ctx,
|
|
||||||
`UPDATE source_content SET checked_at = ? WHERE source_key = ?`,
|
|
||||||
checkedAt.UTC(), string(src.Key()))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to renew source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutSource stores fetched source content with what info says about it: it
|
|
||||||
// writes the file named by the source's key, then the source's row. When the
|
|
||||||
// row cannot be written the file is removed and the store fails, as a file
|
|
||||||
// with no row is never served or evicted; if that removal fails too, the
|
|
||||||
// next reconciliation pass removes the file. On a disabled cache it is a
|
|
||||||
// no-op returning an empty key.
|
|
||||||
func (c *Cache) PutSource(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
src imagerequest.Source,
|
req *imagerequest.Request,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
info SourceInfo,
|
|
||||||
) (imagerequest.SourceKey, error) {
|
) (imagerequest.SourceKey, error) {
|
||||||
if c.config.MaxBytes == 0 {
|
if c.disabled {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,30 +221,22 @@ func (c *Cache) PutSource(
|
|||||||
return "", fmt.Errorf("failed to read source content: %w", err)
|
return "", fmt.Errorf("failed to read source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key := src.Key()
|
key := req.Source.Key()
|
||||||
|
|
||||||
err = c.srcContent.Write(string(key), data)
|
size, err := c.srcContent.Store(key, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
size := int64(len(data))
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (source_key, size_bytes, fetched_at,
|
INSERT INTO source_content (source_key, size_bytes)
|
||||||
checked_at, upstream_etag, upstream_last_modified)
|
VALUES (?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(source_key) DO UPDATE SET
|
ON CONFLICT(source_key) DO UPDATE SET
|
||||||
size_bytes = excluded.size_bytes,
|
size_bytes = excluded.size_bytes,
|
||||||
last_accessed_at = CURRENT_TIMESTAMP,
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
fetched_at = excluded.fetched_at,
|
`, string(key), size)
|
||||||
checked_at = excluded.checked_at,
|
|
||||||
upstream_etag = excluded.upstream_etag,
|
|
||||||
upstream_last_modified = excluded.upstream_last_modified
|
|
||||||
`, string(key), size, info.FetchedAt.UTC(), info.CheckedAt.UTC(), info.ETag,
|
|
||||||
info.LastModified)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deleteErr := c.srcContent.Delete(string(key))
|
deleteErr := c.srcContent.Delete(key)
|
||||||
if deleteErr != nil {
|
if deleteErr != nil {
|
||||||
c.log.Warn("failed to remove source file whose row was not written",
|
c.log.Warn("failed to remove source file whose row was not written",
|
||||||
"source_key", key, "error", deleteErr)
|
"source_key", key, "error", deleteErr)
|
||||||
@@ -595,34 +250,78 @@ func (c *Cache) PutSource(
|
|||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failure reports whether src is in the negative cache. It only reads: a
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
// row past its expiry is not a hit, and the eviction goroutine deletes it
|
// it in the size accounting. On a disabled cache it is a no-op. The
|
||||||
// with the other expired rows (see CleanExpired).
|
// accounting insert is best-effort (the startup reconciliation pass
|
||||||
func (c *Cache) Failure(
|
// adopts any variant file that misses its accounting row).
|
||||||
ctx context.Context, src imagerequest.Source,
|
func (c *Cache) StoreVariant(
|
||||||
) (bool, error) {
|
ctx context.Context, cacheKey imagerequest.VariantKey, content io.Reader,
|
||||||
var expiresAt time.Time
|
) error {
|
||||||
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := c.variants.Store(cacheKey, content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content (cache_key, size_bytes)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(cacheKey), size)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to record variant in size accounting",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifyWritePressure()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupSource checks if we have cached source content for a request.
|
||||||
|
// Returns the source key if found, or an empty key if not; a row whose
|
||||||
|
// file is missing is not found. A source found has its access time
|
||||||
|
// handed to the background writer without waiting (see
|
||||||
|
// touchSourceContent); a disabled cache always reports no cached source.
|
||||||
|
func (c *Cache) LookupSource(
|
||||||
|
ctx context.Context, req *imagerequest.Request,
|
||||||
|
) (imagerequest.SourceKey, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := req.Source.Key()
|
||||||
|
|
||||||
|
var rowExists bool
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT expires_at FROM negative_cache
|
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
`, string(key)).Scan(&rowExists)
|
||||||
`, src.Host, src.Path, src.Query).Scan(&expiresAt)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
return "", fmt.Errorf("failed to lookup source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expired := time.Now().After(expiresAt)
|
if !rowExists {
|
||||||
|
return "", nil
|
||||||
return !expired, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutFailure stores a negative cache entry for src, whose fetch failed.
|
// Verify the content file exists
|
||||||
func (c *Cache) PutFailure(ctx context.Context, src imagerequest.Source) error {
|
if !c.srcContent.Exists(key) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.touchSourceContent(key)
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
|
func (c *Cache) StoreNegative(ctx context.Context, req *imagerequest.Request) error {
|
||||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
@@ -631,7 +330,7 @@ func (c *Cache) PutFailure(ctx context.Context, src imagerequest.Source) error {
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
||||||
expires_at = excluded.expires_at
|
expires_at = excluded.expires_at
|
||||||
`, src.Host, src.Path, src.Query, expiresAt)
|
`, req.Source.Host, req.Source.Path, req.Source.Query, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert negative cache: %w", err)
|
return fmt.Errorf("failed to insert negative cache: %w", err)
|
||||||
}
|
}
|
||||||
@@ -639,6 +338,18 @@ func (c *Cache) PutFailure(ctx context.Context, src imagerequest.Source) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSourceContent returns a reader for cached source content by its key,
|
||||||
|
// and the content's size in bytes.
|
||||||
|
func (c *Cache) GetSourceContent(
|
||||||
|
key imagerequest.SourceKey,
|
||||||
|
) (io.ReadCloser, int64, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return nil, 0, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.srcContent.LoadWithSize(key)
|
||||||
|
}
|
||||||
|
|
||||||
// CleanExpired removes the negative cache rows past their expiry, one
|
// CleanExpired removes the negative cache rows past their expiry, one
|
||||||
// batch per statement, until none is left. Once ctx ends it starts no
|
// batch per statement, until none is left. Once ctx ends it starts no
|
||||||
// further batch and returns ctx's error. The eviction goroutine calls it
|
// further batch and returns ctx's error. The eviction goroutine calls it
|
||||||
@@ -662,9 +373,94 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats returns cache statistics.
|
||||||
|
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||||
|
var stats CacheStats
|
||||||
|
|
||||||
|
err := c.readHitAndMissCounts(ctx, &stats)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count and size the cached source images and processed variants from
|
||||||
|
// their tables. A disabled cache holds none, whatever rows an earlier
|
||||||
|
// run left.
|
||||||
|
if !c.disabled {
|
||||||
|
err = c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT (SELECT COUNT(*) FROM source_content)
|
||||||
|
+ (SELECT COUNT(*) FROM variant_content)
|
||||||
|
`).Scan(&stats.TotalItems)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to count cache items for stats", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.TotalSizeBytes, err = c.sumContentSizeBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to sum cache size for stats", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hit rate as a ratio
|
||||||
|
if stats.HitCount+stats.MissCount > 0 {
|
||||||
|
stats.HitRate = float64(stats.HitCount) / float64(stats.HitCount+stats.MissCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||||
|
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
||||||
|
// methods, it only adds to the pending counts, which the goroutine
|
||||||
|
// StartPendingCountWrites starts writes to the database, so it never waits
|
||||||
|
// for the database or fails.
|
||||||
|
func (c *Cache) IncrementStats(hit bool, fetchBytes int64) {
|
||||||
|
if hit {
|
||||||
|
c.pendingHits.Add(1)
|
||||||
|
} else {
|
||||||
|
c.pendingMisses.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.IncrementUpstreamFetch(fetchBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||||
|
// A fetch that read no bytes is not counted.
|
||||||
|
func (c *Cache) IncrementUpstreamFetch(fetchBytes int64) {
|
||||||
|
if fetchBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.pendingUpstreamFetches.Add(1)
|
||||||
|
c.pendingUpstreamFetchBytes.Add(fetchBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
|
func (c *Cache) IncrementTransformCount() {
|
||||||
|
c.pendingTransforms.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
||||||
|
// cache_stats row plus the pending hits and misses (see
|
||||||
|
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
|
||||||
|
// write of the pending counts falls between the two reads.
|
||||||
|
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
|
||||||
|
c.pendingCountsWriteMutex.Lock()
|
||||||
|
defer c.pendingCountsWriteMutex.Unlock()
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT hit_count, miss_count
|
||||||
|
FROM cache_stats WHERE id = 1
|
||||||
|
`).Scan(&stats.HitCount, &stats.MissCount)
|
||||||
|
|
||||||
|
stats.HitCount += c.pendingHits.Load()
|
||||||
|
stats.MissCount += c.pendingMisses.Load()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// touchVariant hands the access time of a variant served from the cache to
|
// touchVariant hands the access time of a variant served from the cache to
|
||||||
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
|
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||||
// next batch. It never waits: while maxPendingAccessTimes access times of
|
// batch. It never waits: while maxPendingAccessTimes access times of
|
||||||
// variants wait to be written, this one is dropped, and the variant only
|
// variants wait to be written, this one is dropped, and the variant only
|
||||||
// looks colder to eviction.
|
// looks colder to eviction.
|
||||||
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
||||||
@@ -675,10 +471,10 @@ func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// touchSourceContent hands the access time of a cached source a miss used to
|
// touchSourceContent hands the access time of a cached source a miss used to
|
||||||
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
|
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||||
// next batch. It never waits: while maxPendingAccessTimes access times of
|
// batch. It never waits: while maxPendingAccessTimes access times of sources
|
||||||
// sources wait to be written, this one is dropped, and the source only looks
|
// wait to be written, this one is dropped, and the source only looks colder
|
||||||
// colder to eviction.
|
// to eviction.
|
||||||
func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
|
func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
|
||||||
select {
|
select {
|
||||||
case c.pendingSourceAccessTimes <- key:
|
case c.pendingSourceAccessTimes <- key:
|
||||||
@@ -686,6 +482,32 @@ func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkNegativeCache checks if a request is in the negative cache. It only
|
||||||
|
// reads: a row past its expiry is not a hit, and the eviction goroutine
|
||||||
|
// deletes it with the other expired rows (see CleanExpired).
|
||||||
|
func (c *Cache) checkNegativeCache(
|
||||||
|
ctx context.Context, req *imagerequest.Request,
|
||||||
|
) (bool, error) {
|
||||||
|
var expiresAt time.Time
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT expires_at FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&expiresAt)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expired := time.Now().After(expiresAt)
|
||||||
|
|
||||||
|
return !expired, nil
|
||||||
|
}
|
||||||
|
|
||||||
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
||||||
// rows past their expiry in one statement, and returns how many it
|
// rows past their expiry in one statement, and returns how many it
|
||||||
// deleted. DELETE ... LIMIT needs a SQLite build option, so the subquery
|
// deleted. DELETE ... LIMIT needs a SQLite build option, so the subquery
|
||||||
@@ -709,44 +531,3 @@ func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// finishOpeningVariant returns a variant file that FileStore.Open opened, with
|
|
||||||
// its information info, as Variant does. It reads the content type from the
|
|
||||||
// file's first bytes and hands the variant's access time to the background
|
|
||||||
// writer (see touchVariant). It closes the file when its first bytes are not
|
|
||||||
// those of an output format.
|
|
||||||
func (c *Cache) finishOpeningVariant(
|
|
||||||
cacheKey imagerequest.VariantKey, file *os.File, info fs.FileInfo,
|
|
||||||
) (VariantFile, error) {
|
|
||||||
format, err := readFormat(file)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
|
|
||||||
return VariantFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.touchVariant(cacheKey)
|
|
||||||
|
|
||||||
return VariantFile{
|
|
||||||
Content: file,
|
|
||||||
Size: info.Size(),
|
|
||||||
ContentType: format.MIMEType(),
|
|
||||||
CheckedAt: info.ModTime(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readFormat reads up to magic.MaxMagicBytes first bytes of a source or
|
|
||||||
// variant file, without moving its offset, and returns the format they show.
|
|
||||||
// Sources and variants are in the same seven formats; a variant is SVG only
|
|
||||||
// as orig of an SVG source. A file shorter than magic.MinMagicBytes is
|
|
||||||
// io.EOF.
|
|
||||||
func readFormat(content io.ReaderAt) (imagerequest.Format, error) {
|
|
||||||
firstBytes := make([]byte, magic.MaxMagicBytes)
|
|
||||||
|
|
||||||
n, err := content.ReadAt(firstBytes, 0)
|
|
||||||
if err != nil && (!errors.Is(err, io.EOF) || n < magic.MinMagicBytes) {
|
|
||||||
return "", fmt.Errorf("failed to read the first bytes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return magic.DetectFormat(firstBytes[:n])
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,17 +15,14 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/magic"
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestVariant_ContentTypeFromFirstBytes stores an image in each output
|
// TestGetVariant_ContentTypeFromFirstBytes stores an image in each output
|
||||||
// format as a variant and checks that Variant serves it whole with the
|
// format as a variant and checks that GetVariant serves it whole with the
|
||||||
// content type of its format, read from the file.
|
// content type of its format, read from the file.
|
||||||
func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
func TestGetVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
processor := imageprocessor.New(imageprocessor.Params{
|
processor := imageprocessor.New(imageprocessor.Params{})
|
||||||
MaxInputBytes: 50 << 20,
|
|
||||||
MaxConcurrentProcessing: 1,
|
|
||||||
})
|
|
||||||
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
||||||
|
|
||||||
for _, format := range []imagerequest.Format{
|
for _, format := range []imagerequest.Format{
|
||||||
@@ -32,7 +30,7 @@ func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|||||||
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
} {
|
} {
|
||||||
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
||||||
imagerequest.FormatJPEG, &imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: 20, Height: 15},
|
Size: imagerequest.Size{Width: 20, Height: 15},
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -49,19 +47,16 @@ func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|||||||
|
|
||||||
key := imagerequest.VariantKey("aabbccdd10" + string(format))
|
key := imagerequest.VariantKey("aabbccdd10" + string(format))
|
||||||
|
|
||||||
err = cache.PutVariant(t.Context(), key, SourceInfo{CheckedAt: time.Now()},
|
err = cache.StoreVariant(t.Context(), key, bytes.NewReader(image))
|
||||||
bytes.NewReader(image))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PutVariant(%s) error = %v", format, err)
|
t.Fatalf("StoreVariant(%s) error = %v", format, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variant, err := cache.Variant(key)
|
reader, size, contentType, err := cache.GetVariant(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Variant(%s) error = %v", format, err)
|
t.Fatalf("GetVariant(%s) error = %v", format, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, size, contentType := variant.Content, variant.Size, variant.ContentType
|
|
||||||
|
|
||||||
served, err := io.ReadAll(reader)
|
served, err := io.ReadAll(reader)
|
||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
|
|
||||||
@@ -71,7 +66,7 @@ func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
||||||
|
|
||||||
if want := format.MIMEType(); contentType != want {
|
if want := imageprocessor.FormatToMIME(format); contentType != want {
|
||||||
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +77,11 @@ func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVariant_RefusesFileOfNoOutputFormat checks that Variant refuses
|
// TestGetVariant_RefusesFileOfNoOutputFormat checks that GetVariant refuses
|
||||||
// a variant file whose first bytes are not those of an output format, with
|
// a variant file whose first bytes are not those of an output format, with
|
||||||
// an error other than ErrNotFound, and that a variant with no file, or any
|
// an error other than ErrNotFound, and that a variant with no file, or any
|
||||||
// variant of a disabled cache, is ErrNotFound.
|
// variant of a disabled cache, is ErrNotFound.
|
||||||
func TestVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
func TestGetVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
@@ -98,6 +93,8 @@ func TestVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
|||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
||||||
|
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
|
errNotAnOutputFormat},
|
||||||
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
||||||
{"empty", nil, io.EOF},
|
{"empty", nil, io.EOF},
|
||||||
}
|
}
|
||||||
@@ -105,40 +102,121 @@ func TestVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
|||||||
for i, tt := range tests {
|
for i, tt := range tests {
|
||||||
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
|
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
|
||||||
|
|
||||||
err := cache.variants.Write(string(key), tt.content)
|
_, err := cache.variants.Store(key, bytes.NewReader(tt.content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
|
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variant, err := cache.Variant(key)
|
reader, _, contentType, err := cache.GetVariant(key)
|
||||||
t.Logf("%s: Variant() error = %v", tt.name, err)
|
t.Logf("%s: GetVariant() error = %v", tt.name, err)
|
||||||
|
|
||||||
reader, contentType := variant.Content, variant.ContentType
|
|
||||||
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
||||||
t.Errorf("%s: Variant() = reader %v, content type %q, error %v; "+
|
t.Errorf("%s: GetVariant() = reader %v, content type %q, error %v; "+
|
||||||
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
variant, err := cache.Variant(testVariantKeyFour)
|
reader, _, _, err := cache.GetVariant(testVariantKeyFour)
|
||||||
|
|
||||||
reader := variant.Content
|
|
||||||
if reader != nil || !errors.Is(err, ErrNotFound) {
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Variant() of a variant with no file = reader %v, error %v; "+
|
t.Errorf("GetVariant() of a variant with no file = reader %v, error %v; "+
|
||||||
"want no reader and %v", reader, err, ErrNotFound)
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
disabled, _ := newEvictionTestCache(t, 0)
|
disabled, _ := newEvictionTestCache(t, 0)
|
||||||
|
|
||||||
variant, err = disabled.Variant(testVariantKeyOne)
|
reader, _, _, err = disabled.GetVariant(testVariantKeyOne)
|
||||||
|
|
||||||
reader = variant.Content
|
|
||||||
if reader != nil || !errors.Is(err, ErrNotFound) {
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Variant() on a disabled cache = reader %v, error %v; "+
|
t.Errorf("GetVariant() on a disabled cache = reader %v, error %v; "+
|
||||||
"want no reader and %v", reader, err, ErrNotFound)
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_Get_MakesAgainVariantOfNoOutputFormat replaces a cached
|
||||||
|
// variant's file with text, and checks that the next request is a miss
|
||||||
|
// answered with the variant made again, and the one after a hit.
|
||||||
|
func TestService_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"), StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replacing the variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the replacement: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"made again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_HitWaitsForNoDatabaseWrite holds the database's one
|
||||||
|
// connection while a cached variant is requested. The hit must be answered
|
||||||
|
// while the connection is still held, and its access time and hit count
|
||||||
|
// must reach the database once it is free.
|
||||||
|
func TestService_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
key := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
getImage(t, svc, req)
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setVariantLastAccessed(t, svc.cache, key, anHourAgo)
|
||||||
|
|
||||||
|
want := readCacheStatsCounters(t, svc.cache)
|
||||||
|
want.hitCount++
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
results := startGet(t.Context(), svc, req)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("the cache hit waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
waitForCounters(t, svc.cache, want)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingCountsWait)
|
||||||
|
|
||||||
|
for countRows(t, svc.cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the hit's access time was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
|
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
|
||||||
// more access time of a variant, and one more of a source, than may wait to
|
// more access time of a variant, and one more of a source, than may wait to
|
||||||
// be written. touchVariant and touchSourceContent must return each time
|
// be written. touchVariant and touchSourceContent must return each time
|
||||||
@@ -161,7 +239,7 @@ func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testin
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
case <-time.After(pendingAccessTimesWait):
|
case <-time.After(pendingCountsWait):
|
||||||
t.Fatal("touchVariant or touchSourceContent waited once the access " +
|
t.Fatal("touchVariant or touchSourceContent waited once the access " +
|
||||||
"times reached the limit")
|
"times reached the limit")
|
||||||
}
|
}
|
||||||
@@ -177,11 +255,11 @@ func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopPendingAccessTimeWrites_WritesPendingAccessTimes hands the access
|
// TestStopPendingCountWrites_WritesPendingAccessTimes hands the access time
|
||||||
// time of one of two variants, three times, then stops the goroutine that
|
// of one of two variants, three times, then stops the goroutine that writes
|
||||||
// writes them, which never started. The stop must write that access time and
|
// them, which never started. The stop must write that access time and
|
||||||
// leave the other variant's.
|
// leave the other variant's.
|
||||||
func TestStopPendingAccessTimeWrites_WritesPendingAccessTimes(t *testing.T) {
|
func TestStopPendingCountWrites_WritesPendingAccessTimes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
@@ -196,9 +274,9 @@ func TestStopPendingAccessTimeWrites_WritesPendingAccessTimes(t *testing.T) {
|
|||||||
cache.touchVariant(testVariantKeyOne)
|
cache.touchVariant(testVariantKeyOne)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cache.StopPendingAccessTimeWrites(t.Context())
|
err := cache.StopPendingCountWrites(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
|
t.Fatalf("StopPendingCountWrites() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, wantTouched := range map[imagerequest.VariantKey]int{
|
for key, wantTouched := range map[imagerequest.VariantKey]int{
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +33,16 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
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 TABLE 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 INTO cache_stats (id) VALUES (1);
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(), schema)
|
_, err = db.ExecContext(t.Context(), schema)
|
||||||
@@ -44,17 +53,58 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupTestCache(t *testing.T) *Cache {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: tmpDir,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCache_Stats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Increment some stats
|
||||||
|
cache.IncrementStats(true, 0)
|
||||||
|
cache.IncrementStats(true, 0)
|
||||||
|
cache.IncrementStats(false, 1024)
|
||||||
|
|
||||||
|
stats, err := cache.Stats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.HitCount != 2 {
|
||||||
|
t.Errorf("Stats() HitCount = %d, want 2", stats.HitCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.MissCount != 1 {
|
||||||
|
t.Errorf("Stats() MissCount = %d, want 1", stats.MissCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCache_CleanExpired(t *testing.T) {
|
func TestCache_CleanExpired(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
cache, _ := NewCache(db, Config{
|
cache, _ := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
NegativeTTL: 1 * time.Millisecond,
|
||||||
MaxBytes: 1 << 30,
|
})
|
||||||
}, prometheus.NewRegistry())
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -103,10 +153,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
_, err := NewCache(db, Config{
|
_, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
MaxBytes: 1 << 30,
|
})
|
||||||
}, prometheus.NewRegistry())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewCache() error = %v", err)
|
t.Fatalf("NewCache() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user