Author SHA1 Message Date
clawbot ce4df5077b Split internal/imgcache into the image proxy and the cache (closes #317)
check / check (push) Canceled after 0s
The new package internal/imageproxy holds Proxy, which was imgcache.Service,
with its Config, ImageResponse, CacheStatus and errors. NewProxy makes and
registers the counters of upstream fetches and transforms. internal/imgcache
holds the cache only, with its hit and miss counters, and imgcache.CacheConfig
is imgcache.Config. The cache reads and writes each kind it keeps with one pair
of methods: Variant and PutVariant, Source and PutSource, Failure and
PutFailure. Source replaces LookupSource followed by GetSourceContent.
imgcache.go and module.go are gone. The proxy's tests moved with it. The
cache's tests check that the reconciliation pass removes an empty or cut file
and its row. The proxy's tests remove them by hand, then check that Get fetches
or converts again.

Model: opus-5-5
2026-10-26 18:39:15 +00:00
60 changed files with 2604 additions and 3195 deletions
+759 -1017
View File
File diff suppressed because it is too large Load Diff
+5 -106
View File
@@ -30,112 +30,11 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-27 the reconciliation pass skips a directory it cannot list and a file
whose info it cannot read, and goes on (closes #343). It stopped before its
later checks at a directory it could not list, or at a source or variant file
whose info it could not read.
- It logs the warning "skipped unreadable entry in cache directory", with
the path and the error.
- A file renamed or removed after its directory was listed gets no warning.
- `reconcileFiles` reads each file's info once, before the temp-file check,
and hands it to `sweepStaleTempFile`, `reconcileVariantFile` or
`reconcileSourceFile`.
- A cancelled context still stops the walk with its error.
- 2026-10-26 the reconciliation pass drops a variant's accounting row only when
its file is missing (closes #340). Any other error reading the file's size
keeps the row. It dropped the row on any error and logged the file as missing.
- The kept row gets the warning "kept accounting row for unreadable variant
file", with the variant key and the error.
- The pass checks for `ErrNotFound`, which `FileStore.Size` returns for a
missing file.
- 2026-10-26 on both image routes, a request is answered 499 and logged at info
level when its client goes away before its image is ready, unless it is the
request making the image (closes #339). It was answered 500 and logged at
error level, as pixa's own failure.
- The line is "client went away before the image was ready". It carries the
`request_id`, the host and the path.
- The 499 has no body, since nobody reads it.
- `serveImage` answers it before calling `respondImageError`, which cannot
see the request's context.
- An error wrapping `context.Canceled` while the request's context is live
is still answered 500 and logged at error level.
- 2026-10-26 `README.md`'s Design section starts with a Concepts list (closes
#321). Each concept gets one sentence, under the name the code gives it.
- Storage calls the transformed images variants, in shorter sentences.
- Storage's paths end in the source key and the variant key.
- Storage lists the image cache's tables in the database.
- Architecture names `internal/providers`, and lists the packages that work
with the concepts.
- 2026-10-26 one table in `internal/config` lists every setting once (closes
#320). `Config.settings` gives each setting's key, its environment variable
and the function that loads it.
- Most rows load through `field`, whose arguments give the setting's type,
its default and its check.
- `db_url` and `cache_max_bytes` have their own load functions. The default
of `db_url` comes from `state_dir`, and `cache_max_bytes` records whether
it is set.
- Loading, the unknown-key check, the unknown-variable check and the names
in error messages all read the table.
- `allowlist_hosts` loads as `referer_blocklist` does, through
`parseHostList`.
- The metrics username and password are checked together once the table has
loaded.
- When a config holds more than one invalid value, the error names the one
that comes first in the table.
- `README.md` lists the settings one item each, where it had a table. A test
checks that this list and `configs/config.example.yml` name exactly the
keys of the table.
- The key constants moved to the tests, their only users.
- Removed:
- `isKnownConfigKey`, `envVarNames`, `strictLoader` and `validate`
- `validateAllowlistHostsValue` and `getStringSlice`, with the three
tests of `getStringSlice`
- 2026-10-26 a fetched image's format is detected from its bytes once, as an
`imagerequest.Format` (closes #319). The upstream `Content-Type` check in the
fetcher stays as the filter before downloading.
- `magic.DetectFormat` returns an `imagerequest.Format`, and
`magic.ValidateMagicBytes` returns the format it checked against the
`Content-Type`.
- `Format.MIMEType` gives each format's MIME type. The fetcher, the mock
fetcher, the cache's read of a variant's type and the handler's `Accept`
matching use it.
- `Cache.Source` returns a source's format, read from its file's first bytes
as `Cache.Variant` reads a variant's. A file in no source format is an
error, and the proxy fetches the source again.
- The proxy hands the format to `ImageProcessor.Process`. The processor
refuses input that libvips would load as another format, as before, with
`ErrUnsupportedInputFormat`. It refuses a format that is not one of the
six source formats the same way.
- `internal/magic` no longer detects SVG, which pixa does not read. SVG data
is `ErrUnknownFormat`.
- Removed:
- `magic.MIMEType`, `magic.ImageFormat` and `magic.MIMEToImageFormat`
- `detectFormat`, `formatFromString`, `FormatToMIME` and
`Result.InputFormat` of the processor
- the fetcher's MIME constants of the six formats
- `errNotAnOutputFormat` of the cache
- 2026-10-26 the cache keeps its source images in one `imgcache.FileStore` and
its variants in another (closes #318). A `FileStore` is a directory of files,
each named by a hex key, at `<ab>/<cd>/<key>`.
- `ContentStorage` and `VariantStorage` are gone.
- `FileStore` has these methods:
- `Write`, which writes a file under a temp name, syncs it and renames
it into place
- `Open`, which returns the file and its size
- `Size`
- `Delete`
- `FileStore` has no `Exists`. The reconciliation pass checks that a
variant's file is there with `Size`. The tests look for the file on disk.
- `Cache.Variant` reads the content type from the variant file's first
bytes, as `VariantStorage.LoadWithContentType` did. A cache hit still
makes no database call.
- `storage.go` is now `filestore.go`. `internal/imgcache/testdata/red.avif`,
which no test used, is gone.
- 2026-10-26 `internal/imgcache` holds the cache only (closes #317). The new - 2026-10-26 `internal/imgcache` holds the cache only (closes #317). The new
package `internal/imageproxy` fetches, transforms, caches and serves images. package `internal/imageproxy` fetches, transforms, caches and serves images.
It imports `imgcache`, and `imgcache` does not import it. It imports `imgcache`, and `imgcache` does not import it.
- `imgcache.Service` is `imageproxy.Proxy`, built by `imageproxy.New` from - `imgcache.Service` is `imageproxy.Proxy`, built by `imageproxy.NewProxy`
an `imageproxy.Config`. from an `imageproxy.Config`.
- `ImageResponse`, `CacheStatus`, `ErrNegativeCached` and - `ImageResponse`, `CacheStatus`, `ErrNegativeCached` and
`ErrProcessingTimedOut` moved with it, under their names. `ErrProcessingTimedOut` moved with it, under their names.
- `imgcache.CacheConfig` is `imgcache.Config`. - `imgcache.CacheConfig` is `imgcache.Config`.
@@ -150,9 +49,9 @@ P2: security: per-origin rate limiting
- `Variant` and `Source` each open the file and hand its access time to the - `Variant` and `Source` each open the file and hand its access time to the
background writer. A cache hit still makes no database call. background writer. A cache hit still makes no database call.
- `imgcache.go` and `module.go` are gone. - `imgcache.go` and `module.go` are gone.
- `imageproxy.New` makes the counters of upstream fetches and transforms, - `NewProxy` makes the counters of upstream fetches and transforms, and
and registers them on the registry it is given. The cache keeps only the registers them on the registry it is given. The cache keeps only the hit
hit and miss counters. and miss counters.
- A cached source whose row cannot be read is logged as "failed to load - A cached source whose row cannot be read is logged as "failed to load
cached source, fetching". It was logged as "source lookup failed". cached source, fetching". It was logged as "source lookup failed".
- The proxy's tests moved to `internal/imageproxy`. They reach the cache - The proxy's tests moved to `internal/imageproxy`. They reach the cache
+586 -345
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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)
}
}
+3 -4
View File
@@ -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()
+2 -2
View File
@@ -132,7 +132,7 @@ func newCountingDatabaseServer(
tb.Fatalf("imgcache.NewCache() error = %v", err) tb.Fatalf("imgcache.NewCache() error = %v", err)
} }
svc, err := imageproxy.New(&imageproxy.Config{ svc, err := imageproxy.NewProxy(&imageproxy.Config{
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()},
@@ -145,7 +145,7 @@ func newCountingDatabaseServer(
Logger: slog.New(slog.DiscardHandler), Logger: slog.New(slog.DiscardHandler),
}, prometheus.NewRegistry()) }, prometheus.NewRegistry())
if err != nil { if err != nil {
tb.Fatalf("imageproxy.New() error = %v", err) tb.Fatalf("imageproxy.NewProxy() error = %v", err)
} }
keys, err := seal.DeriveKeys(testSigningKey) keys, err := seal.DeriveKeys(testSigningKey)
@@ -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,
) (*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`)
}
})
}
}
@@ -48,7 +48,7 @@ func newRoutesFetchingThrough(
t.Fatalf("imgcache.NewCache() error = %v", err) t.Fatalf("imgcache.NewCache() error = %v", err)
} }
svc, err := imageproxy.New(&imageproxy.Config{ svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache, Cache: cache,
Fetcher: fetcher, Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{ Processor: imageprocessor.New(imageprocessor.Params{
@@ -59,7 +59,7 @@ func newRoutesFetchingThrough(
Logger: log, Logger: log,
}, prometheus.NewRegistry()) }, prometheus.NewRegistry())
if err != nil { if err != nil {
t.Fatalf("imageproxy.New() error = %v", err) t.Fatalf("imageproxy.NewProxy() error = %v", err)
} }
keys, err := seal.DeriveKeys(testSigningKey) keys, err := seal.DeriveKeys(testSigningKey)
+4 -4
View File
@@ -65,20 +65,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/*"]
} }
+1 -1
View File
@@ -59,7 +59,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
t.Fatalf("failed to create cache: %v", err) t.Fatalf("failed to create cache: %v", err)
} }
svc, err := imageproxy.New(&imageproxy.Config{ svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache, Cache: cache,
Fetcher: newMockFetcher(mockFS), Fetcher: newMockFetcher(mockFS),
Processor: imageprocessor.New(imageprocessor.Params{ Processor: imageprocessor.New(imageprocessor.Params{
+1 -24
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -20,12 +19,6 @@ import (
"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>
@@ -352,8 +345,7 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
// serveImage serves the image that req asks for. Both image routes call it // serveImage serves the image that req asks for. Both image routes call it
// once they have the request and its URL's expiry, which ends the // once they have the request and its URL's expiry, which ends the
// Cache-Control max-age. It chooses the format for the format auto, answers // Cache-Control max-age. It chooses the format for the format auto, answers
// If-None-Match with 304 and HEAD with the headers only. It answers 499 when // If-None-Match with 304 and HEAD with the headers only, and answers an error
// the image service stopped because the client went away, and any other error
// of the image service with respondImageError. // of the image service with respondImageError.
func (s *Handlers) serveImage( func (s *Handlers) serveImage(
w http.ResponseWriter, r *http.Request, w http.ResponseWriter, r *http.Request,
@@ -370,21 +362,6 @@ func (s *Handlers) serveImage(
startTime := time.Now() startTime := time.Now()
resp, err := s.imgSvc.Get(r.Context(), req) 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 { if err != nil {
s.respondImageError(w, req, err) s.respondImageError(w, req, err)
@@ -47,7 +47,7 @@ func newSignedHostServer(
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 := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache, Cache: cache,
Fetcher: newMockFetcher(fstest.MapFS{ Fetcher: newMockFetcher(fstest.MapFS{
signedHost + photoPath: &fstest.MapFile{Data: jpegData}, signedHost + photoPath: &fstest.MapFile{Data: jpegData},
@@ -60,7 +60,7 @@ func newSignedHostServer(
Logger: log, Logger: log,
}, prometheus.NewRegistry()) }, prometheus.NewRegistry())
if err != nil { if err != nil {
t.Fatalf("imageproxy.New() error = %v", err) t.Fatalf("imageproxy.NewProxy() error = %v", err)
} }
keys, err := seal.DeriveKeys(testSigningKey) keys, err := seal.DeriveKeys(testSigningKey)
@@ -46,7 +46,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
t.Fatalf("failed to create cache: %v", err) t.Fatalf("failed to create cache: %v", err)
} }
svc, err := imageproxy.New(&imageproxy.Config{ svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache, Cache: cache,
Fetcher: fetcher, Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{ Processor: imageprocessor.New(imageprocessor.Params{
@@ -62,7 +62,7 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
t.Fatalf("imgcache.NewCache() error = %v", err) t.Fatalf("imgcache.NewCache() error = %v", err)
} }
svc, err := imageproxy.New(&imageproxy.Config{ svc, err := imageproxy.NewProxy(&imageproxy.Config{
Cache: cache, Cache: cache,
Fetcher: fetcher, Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{ Processor: imageprocessor.New(imageprocessor.Params{
@@ -72,7 +72,7 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
MaxResponseSize: 50 << 20, MaxResponseSize: 50 << 20,
}, prometheus.NewRegistry()) }, prometheus.NewRegistry())
if err != nil { if err != nil {
t.Fatalf("imageproxy.New() error = %v", err) t.Fatalf("imageproxy.NewProxy() error = %v", err)
} }
keys, err := seal.DeriveKeys(testSigningKey) keys, err := seal.DeriveKeys(testSigningKey)
@@ -16,8 +16,8 @@ import (
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which // TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
// answers the errors of both image routes, answers 415 with a clear error when // answers the errors of both image routes, answers 415 with a clear error when
// the upstream answers with a content type pixa does not accept, such as SVG, // the upstream answers with a content type pixa does not accept, such as SVG,
// and when libvips does not read a source as the format its first bytes // and when orig is asked of a source in a format the processor cannot name,
// show, wrapped as the service wraps them. // wrapped as the service wraps them.
func TestUnsupportedSourceFormatAnswers415(t *testing.T) { func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
t.Parallel() t.Parallel()
@@ -29,7 +29,7 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
for _, err := range []error{ for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w: image/svg+xml", fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
httpfetcher.ErrInvalidContentType), httpfetcher.ErrInvalidContentType),
fmt.Errorf("image processing failed: %w: libvips does not read the input as avif", fmt.Errorf("image processing failed: %w: unknown",
imageprocessor.ErrUnsupportedInputFormat), imageprocessor.ErrUnsupportedInputFormat),
} { } {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+3 -3
View File
@@ -83,7 +83,7 @@ func startUpstream(t *testing.T) *httptest.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", 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) {
@@ -358,7 +358,7 @@ func TestFetchContentLengthBodyAtMaxResponseSize(t *testing.T) {
res := fetchWithMaxResponseSize(t, maxResponseSize, res := fetchWithMaxResponseSize(t, maxResponseSize,
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", 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 +406,7 @@ func TestFetchChunkedBodyAtMaxResponseSize(t *testing.T) {
res := fetchWithMaxResponseSize(t, maxResponseSize, res := fetchWithMaxResponseSize(t, maxResponseSize,
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", 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.
+19 -8
View File
@@ -38,6 +38,18 @@ const userAgent = "pixa/1.0"
// 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"
@@ -109,16 +121,15 @@ type FetchResult struct {
} }
// allowedContentTypes returns the content types an upstream response may // allowedContentTypes returns the content types an upstream response may
// have, which Fetch also sends as its Accept header: the MIME types of the // have, which Fetch also sends as its Accept header.
// formats pixa reads.
func allowedContentTypes() []string { func allowedContentTypes() []string {
return []string{ return []string{
imagerequest.FormatJPEG.MIMEType(), contentTypeJPEG,
imagerequest.FormatPNG.MIMEType(), contentTypePNG,
imagerequest.FormatGIF.MIMEType(), contentTypeGIF,
imagerequest.FormatWebP.MIMEType(), contentTypeWebP,
imagerequest.FormatAVIF.MIMEType(), contentTypeAVIF,
imagerequest.FormatJXL.MIMEType(), contentTypeJXL,
} }
} }
@@ -30,9 +30,9 @@ func TestIsAllowedContentType(t *testing.T) {
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},
@@ -217,7 +217,7 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
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)
} }
@@ -272,13 +272,13 @@ 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", contentTypeSVG}, {"foo/bar.svg", contentTypeSVG},
{"foo/bar.bin", contentTypeOctetStream}, {"foo/bar.bin", contentTypeOctetStream},
{"foo/bar", contentTypeOctetStream}, {"foo/bar", contentTypeOctetStream},
+6 -12
View File
@@ -66,29 +66,23 @@ func (m *MockFetcher) Fetch(
}, nil }, nil
} }
// The content types the mock fetcher gives files in no format pixa reads.
const (
contentTypeSVG = "image/svg+xml"
contentTypeOctetStream = "application/octet-stream"
)
// 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 {
path = strings.ToLower(path) path = strings.ToLower(path)
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 contentTypeSVG return contentTypeSVG
default: default:
@@ -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)
@@ -39,31 +39,30 @@ 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 {
@@ -73,7 +72,7 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
_, err := New(Params{ _, err := New(Params{
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), bytes.NewReader(tt.input), tt.sourceFormat, }).Process(t.Context(), bytes.NewReader(tt.input),
&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,
@@ -42,7 +42,7 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatJPEG, bytes.NewReader(input),
&imagerequest.Transform{ &imagerequest.Transform{
Size: tt.size, Size: tt.size,
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
@@ -111,7 +111,7 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatJPEG, bytes.NewReader(input),
&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
@@ -88,8 +86,7 @@ func checkProcessStopsAtDeadline(
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1}) proc := New(Params{MaxInputBytes: 50 << 20, 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)
@@ -19,7 +19,7 @@ func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process( }).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,17 @@ 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{
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), bytes.NewReader(input), sourceFormat, transform) }).Process(t.Context(), bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -75,7 +74,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 +92,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 +134,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 +161,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()
@@ -40,7 +40,7 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatJPEG, bytes.NewReader(input),
&imagerequest.Transform{ &imagerequest.Transform{
Size: tt.size, Size: tt.size,
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
@@ -113,7 +113,7 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatPNG, bytes.NewReader(input),
&imagerequest.Transform{ &imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50}, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatPNG, Format: imagerequest.FormatPNG,
@@ -240,7 +240,7 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatPNG, bytes.NewReader(input),
&imagerequest.Transform{ &imagerequest.Transform{
Size: tt.size, Size: tt.size,
Format: imagerequest.FormatPNG, Format: imagerequest.FormatPNG,
@@ -45,7 +45,7 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatJPEG, bytes.NewReader(input),
&imagerequest.Transform{ &imagerequest.Transform{
Size: tt.size, Size: tt.size,
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
@@ -102,7 +102,7 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatJPEG, bytes.NewReader(input),
&imagerequest.Transform{ &imagerequest.Transform{
Size: tt.size, Size: tt.size,
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
+92 -55
View File
@@ -70,6 +70,8 @@ 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.
@@ -111,10 +113,9 @@ 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 six pixa reads is also // first-bytes check for AVIF.
// refused with it.
var ErrUnsupportedInputFormat = errors.New("unsupported input format") var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being // ErrTooManyImages is returned when MaxConcurrentProcessing images are being
@@ -156,10 +157,9 @@ func New(params Params) *ImageProcessor {
} }
} }
// 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
@@ -168,7 +168,6 @@ func New(params Params) *ImageProcessor {
func (p *ImageProcessor) Process( func (p *ImageProcessor) Process(
ctx context.Context, ctx context.Context,
input io.Reader, input io.Reader,
sourceFormat imagerequest.Format,
transform *imagerequest.Transform, transform *imagerequest.Transform,
) (*Result, error) { ) (*Result, error) {
release, err := p.acquireSlot(ctx) release, err := p.acquireSlot(ctx)
@@ -183,9 +182,13 @@ func (p *ImageProcessor) Process(
return nil, err return nil, err
} }
// Input libvips would not load as sourceFormat is refused before it is // govips picks the libvips loader from the input's first bytes with
// decoded // DetermineImageType. Input whose first bytes it reads as no source
err = checkInputFormat(data, sourceFormat) // format, such as SVG, which librsvg would render, is refused before
// libvips reads it.
inputFormat := p.detectFormat(vips.DetermineImageType(data))
sourceFormat, err := p.formatFromString(inputFormat)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -251,11 +254,12 @@ func (p *ImageProcessor) Process(
return &Result{ return &Result{
Content: io.NopCloser(bytes.NewReader(output)), Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)), ContentLength: int64(len(output)),
ContentType: outputFormat.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
} }
@@ -290,6 +294,38 @@ func targetDimensions(
return width, height, nil return width, height, nil
} }
// MIME types for the supported image formats.
const (
mimeJPEG = "image/jpeg"
mimePNG = "image/png"
mimeGIF = "image/gif"
mimeWebP = "image/webp"
mimeAVIF = "image/avif"
mimeJXL = "image/jxl"
)
// FormatToMIME converts a Format to its MIME type string.
func FormatToMIME(format imagerequest.Format) string {
switch format {
case imagerequest.FormatJPEG:
return mimeJPEG
case imagerequest.FormatPNG:
return mimePNG
case imagerequest.FormatWebP:
return mimeWebP
case imagerequest.FormatGIF:
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"
}
}
// WaitForProcessing waits until no image is being processed, or until ctx // WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then. It // ends, and returns how many images were still being processed then. It
// waits by taking each slot in processingSemaphore as it frees up until it // waits by taking each slot in processingSemaphore as it frees up until it
@@ -358,49 +394,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.FormatOriginal, imagerequest.FormatAuto:
return vips.ImageTypeUnknown
default: default:
return vips.ImageTypeUnknown return "unknown"
} }
} }
@@ -754,3 +768,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)
}
}
@@ -94,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 ""
@@ -131,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)
} }
@@ -158,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)
} }
} }
@@ -177,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)
} }
@@ -191,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)
} }
} }
@@ -215,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)
} }
@@ -256,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)
} }
@@ -300,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)
} }
@@ -346,8 +341,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")
} }
@@ -375,8 +369,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",
@@ -406,8 +399,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)
} }
@@ -438,7 +430,7 @@ 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) {
@@ -461,8 +453,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)
} }
@@ -476,7 +467,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)
} }
} }
@@ -503,8 +494,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")
} }
@@ -531,8 +521,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)
} }
@@ -543,11 +532,11 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
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 {
@@ -557,8 +546,7 @@ func processAndDecode(
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process( }).Process(
context.Background(), bytes.NewReader(input), imagerequest.FormatJPEG, context.Background(), bytes.NewReader(input), transform,
transform,
) )
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
@@ -25,8 +25,7 @@ func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process( }).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
imagerequest.FormatJPEG, transform,
) )
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
@@ -72,7 +71,7 @@ func TestImageProcessor_JPEGXLQuality(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process(t.Context(), }).Process(t.Context(),
bytes.NewReader(input), imagerequest.FormatJPEG, bytes.NewReader(input),
&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)
@@ -93,8 +93,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()
} }
@@ -207,7 +206,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 +274,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 +284,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)
} }
@@ -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()
@@ -32,7 +31,7 @@ func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
MaxInputBytes: 50 << 20, MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1, MaxConcurrentProcessing: 1,
}).Process( }).Process(
t.Context(), bytes.NewReader(tiff), imagerequest.FormatJPEG, t.Context(), bytes.NewReader(tiff),
&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"
@@ -22,7 +21,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
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 six, such as orig, auto or none, is refused with
// ErrUnsupportedInputFormat. The input is text, which libvips reads as no
// format.
func TestImageProcessor_NotASourceFormatRefused(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
for _, sourceFormat := range []imagerequest.Format{
imagerequest.FormatOriginal, imagerequest.FormatAuto, "",
} {
result, err := proc.Process(t.Context(),
bytes.NewReader([]byte("this is text, not an image")), sourceFormat,
&imagerequest.Transform{Format: imagerequest.FormatJPEG})
t.Logf("source format %q: error = %v", sourceFormat, err)
if result != nil || !errors.Is(err, ErrUnsupportedInputFormat) {
t.Errorf("Process() with source format %q = %v, error %v; want %v",
sourceFormat, result, err, ErrUnsupportedInputFormat)
} }
} }
} }
+16 -15
View File
@@ -9,10 +9,11 @@ import (
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
// TestNew_RegistersItsCountersOnItsRegistry checks that New registers the // TestNewProxy_RegistersItsCountersOnItsRegistry checks that NewProxy
// proxy's counters on the registry it is given, under the names README.md // registers the proxy's counters on the registry it is given, under the
// gives them. The cache registers its own counters on another registry. // names README.md gives them. The cache registers its own counters on
func TestNew_RegistersItsCountersOnItsRegistry(t *testing.T) { // another registry.
func TestNewProxy_RegistersItsCountersOnItsRegistry(t *testing.T) {
t.Parallel() t.Parallel()
cache, err := imgcache.NewCache(setupTestDB(t), cache, err := imgcache.NewCache(setupTestDB(t),
@@ -24,9 +25,9 @@ func TestNew_RegistersItsCountersOnItsRegistry(t *testing.T) {
registry := prometheus.NewRegistry() registry := prometheus.NewRegistry()
_, err = New(&Config{Cache: cache}, registry) _, err = NewProxy(&Config{Cache: cache}, registry)
if err != nil { if err != nil {
t.Fatalf("New() error = %v", err) t.Fatalf("NewProxy() error = %v", err)
} }
names := []string{ names := []string{
@@ -46,10 +47,10 @@ func TestNew_RegistersItsCountersOnItsRegistry(t *testing.T) {
} }
} }
// TestNew_FailsWhenItsCountersCannotBeRegistered makes a second proxy on // TestNewProxy_FailsWhenItsCountersCannotBeRegistered makes a second proxy
// the registry of a first. Its counters have the same names, so the // on the registry of a first. Its counters have the same names, so the
// registry refuses them, and New must return that error. // registry refuses them, and NewProxy must return that error.
func TestNew_FailsWhenItsCountersCannotBeRegistered(t *testing.T) { func TestNewProxy_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
t.Parallel() t.Parallel()
cache, err := imgcache.NewCache(setupTestDB(t), cache, err := imgcache.NewCache(setupTestDB(t),
@@ -61,17 +62,17 @@ func TestNew_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
registry := prometheus.NewRegistry() registry := prometheus.NewRegistry()
_, err = New(&Config{Cache: cache}, registry) _, err = NewProxy(&Config{Cache: cache}, registry)
if err != nil { if err != nil {
t.Fatalf("New() of the first proxy error = %v", err) t.Fatalf("NewProxy() of the first proxy error = %v", err)
} }
_, err = New(&Config{Cache: cache}, registry) _, err = NewProxy(&Config{Cache: cache}, registry)
t.Logf("New() of the second proxy error = %v", err) t.Logf("NewProxy() of the second proxy error = %v", err)
var alreadyRegistered prometheus.AlreadyRegisteredError var alreadyRegistered prometheus.AlreadyRegisteredError
if !errors.As(err, &alreadyRegistered) { if !errors.As(err, &alreadyRegistered) {
t.Errorf("New() of the second proxy error = %v, want %T", t.Errorf("NewProxy() of the second proxy error = %v, want %T",
err, alreadyRegistered) err, alreadyRegistered)
} }
} }
+29 -27
View File
@@ -37,7 +37,7 @@ type Proxy struct {
variantsInProgress singleflight.Group variantsInProgress singleflight.Group
// The counts of upstream fetches and transforms, made and registered by // The counts of upstream fetches and transforms, made and registered by
// New. They are kept in memory, so adding to one waits for nothing. // NewProxy. They are kept in memory, so adding to one waits for nothing.
upstreamFetches prometheus.Counter upstreamFetches prometheus.Counter
upstreamFetchBytes prometheus.Counter upstreamFetchBytes prometheus.Counter
transforms prometheus.Counter transforms prometheus.Counter
@@ -61,11 +61,11 @@ type Config struct {
// errCacheRequired is returned when the proxy is built without a cache. // errCacheRequired is returned when the proxy is built without a cache.
var errCacheRequired = errors.New("cache is required") var errCacheRequired = errors.New("cache is required")
// New creates a new image proxy and registers its counters on // NewProxy creates a new image proxy and registers its counters on
// metricsRegistry. pixad passes Prometheus' default registry, the one // metricsRegistry. pixad passes Prometheus' default registry, the one
// /metrics serves. It fails when metricsRegistry already holds counters of // /metrics serves. It fails when metricsRegistry already holds counters of
// the same names. // the same names.
func New( func NewProxy(
cfg *Config, metricsRegistry prometheus.Registerer, cfg *Config, metricsRegistry prometheus.Registerer,
) (*Proxy, error) { ) (*Proxy, error) {
if cfg.Cache == nil { if cfg.Cache == nil {
@@ -303,22 +303,21 @@ func withoutCancelKeepingDeadline(
return context.WithDeadline(context.WithoutCancel(ctx), deadline) return context.WithDeadline(context.WithoutCancel(ctx), deadline)
} }
// loadCachedSource opens src's cached source, reading only the first bytes, // loadCachedSource opens src's cached source, without reading it, and
// which give its format, and returns it with its size and its format. It // returns it with its size; nil if it is not in the cache, cannot be read,
// returns nil if the source is not in the cache, cannot be read, is in no // is empty or exceeds maxResponseSize.
// source format or exceeds maxResponseSize.
func (p *Proxy) loadCachedSource( func (p *Proxy) loadCachedSource(
ctx context.Context, src imagerequest.Source, ctx context.Context, src imagerequest.Source,
) (io.ReadCloser, int64, imagerequest.Format) { ) (io.ReadCloser, int64) {
reader, size, format, err := p.cache.Source(ctx, src) reader, size, err := p.cache.Source(ctx, src)
if errors.Is(err, imgcache.ErrNotFound) { if errors.Is(err, imgcache.ErrNotFound) {
return nil, 0, "" return nil, 0
} }
if err != nil { if err != nil {
p.log.Warn("failed to load cached source, fetching", "error", err) p.log.Warn("failed to load cached source, fetching", "error", err)
return nil, 0, "" return nil, 0
} }
p.log.Debug("using cached source", "source_key", src.Key()) p.log.Debug("using cached source", "source_key", src.Key())
@@ -331,10 +330,16 @@ func (p *Proxy) loadCachedSource(
"max_bytes", p.maxResponseSize, "max_bytes", p.maxResponseSize,
) )
return nil, 0, "" return nil, 0
} }
return reader, size, format if size == 0 {
_ = reader.Close()
return nil, 0
}
return reader, size
} }
// processFromSourceOrFetch processes an image, using cached source content // processFromSourceOrFetch processes an image, using cached source content
@@ -344,7 +349,7 @@ func (p *Proxy) processFromSourceOrFetch(
req *imagerequest.Request, req *imagerequest.Request,
cacheKey imagerequest.VariantKey, cacheKey imagerequest.VariantKey,
) (*processedVariant, error) { ) (*processedVariant, error) {
source, sourceSize, sourceFormat := p.loadCachedSource(ctx, req.Source) source, sourceSize := p.loadCachedSource(ctx, req.Source)
// Fetch from upstream if we don't have source data or it's empty // Fetch from upstream if we don't have source data or it's empty
if source == nil { if source == nil {
@@ -356,7 +361,7 @@ func (p *Proxy) processFromSourceOrFetch(
// Process using cached source; nothing was fetched from upstream. The // Process using cached source; nothing was fetched from upstream. The
// image processor reads the source only once it has a processing slot, // image processor reads the source only once it has a processing slot,
// so a request waiting for one holds none of it in memory. // so a request waiting for one holds none of it in memory.
return p.processAndStore(ctx, req, cacheKey, source, sourceFormat, sourceSize) return p.processAndStore(ctx, req, cacheKey, source, sourceSize)
} }
// fetchAndProcess fetches from upstream, processes, and caches the result. // fetchAndProcess fetches from upstream, processes, and caches the result.
@@ -437,11 +442,10 @@ func (p *Proxy) fetchAndProcess(
"cipher", fetchResult.TLSCipherSuite, "cipher", fetchResult.TLSCipherSuite,
) )
// The source's format, detected from its first bytes, which must match // Validate magic bytes match content type. A body that does not is the
// its content type. A body that does not is the upstream's error, so it // upstream's error, so it gets a negative cache entry, as an upstream
// gets a negative cache entry, as an upstream error status does. // error status does.
sourceFormat, err := magic.ValidateMagicBytes(sourceData, err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
fetchResult.ContentType)
if err != nil { if err != nil {
p.log.Warn("source refused", "request_id", middleware.GetReqID(ctx), p.log.Warn("source refused", "request_id", middleware.GetReqID(ctx),
"host", req.Source.Host, "path", req.Source.Path, "host", req.Source.Host, "path", req.Source.Path,
@@ -459,7 +463,7 @@ func (p *Proxy) fetchAndProcess(
} }
return p.processAndStore( return p.processAndStore(
ctx, req, cacheKey, bytes.NewReader(sourceData), sourceFormat, fetchBytes, ctx, req, cacheKey, bytes.NewReader(sourceData), fetchBytes,
) )
} }
@@ -493,14 +497,13 @@ type processedVariant struct {
fetchedBytes int64 fetchedBytes int64
} }
// processAndStore processes the image read from source, which is in // processAndStore processes the image read from source and stores the
// sourceFormat, and stores the result. // result.
func (p *Proxy) processAndStore( func (p *Proxy) processAndStore(
ctx context.Context, ctx context.Context,
req *imagerequest.Request, req *imagerequest.Request,
cacheKey imagerequest.VariantKey, cacheKey imagerequest.VariantKey,
source io.Reader, source io.Reader,
sourceFormat imagerequest.Format,
fetchBytes int64, fetchBytes int64,
) (*processedVariant, error) { ) (*processedVariant, error) {
// Process the image // Process the image
@@ -508,8 +511,7 @@ func (p *Proxy) processAndStore(
// ctx has the deadline of the request doing the processing, which can // ctx has the deadline of the request doing the processing, which can
// end while a request waiting for its image has time left // end while a request waiting for its image has time left
processResult, err := p.processor.Process(ctx, source, sourceFormat, processResult, err := p.processor.Process(ctx, source, &req.Transform)
&req.Transform)
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err) return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
} }
@@ -544,7 +546,7 @@ func (p *Proxy) processAndStore(
"request_id", middleware.GetReqID(ctx), "request_id", middleware.GetReqID(ctx),
"host", req.Source.Host, "host", req.Source.Host,
"path", req.Source.Path, "path", req.Source.Path,
"src_format", sourceFormat, "src_format", processResult.InputFormat,
"dst_format", req.Transform.Format, "dst_format", req.Transform.Format,
"src_bytes", fetchBytes, "src_bytes", fetchBytes,
"dst_bytes", outputSize, "dst_bytes", outputSize,
@@ -191,14 +191,23 @@ func TestProxy_Get_FormatConversion(t *testing.T) {
t.Fatalf("failed to read response: %v", err) t.Fatalf("failed to read response: %v", err)
} }
detectedFormat, err := magic.DetectFormat(data) detectedMIME, err := magic.DetectFormat(data)
if err != nil { if err != nil {
t.Fatalf("failed to detect format: %v", err) t.Fatalf("failed to detect format: %v", err)
} }
if detectedFormat.MIMEType() != tt.wantMIME { expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
t.Errorf("detected format = %q, want the format of %s", if !ok {
detectedFormat, tt.wantMIME) t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
}
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
if !ok {
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
}
if detectedFormat != expectedFormat {
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
} }
}) })
} }
@@ -41,8 +41,7 @@ func holdProcessingSlot(
input, feed := io.Pipe() input, feed := io.Pipe()
go func() { go func() {
_, _ = proc.Process(t.Context(), input, imagerequest.FormatJPEG, _, _ = proc.Process(t.Context(), input, &imagerequest.Transform{})
&imagerequest.Transform{})
}() }()
_, err := feed.Write([]byte{0}) _, err := feed.Write([]byte{0})
@@ -58,11 +57,10 @@ func holdProcessingSlot(
// TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a // TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a
// request whose source is cached holds none of it while it waits for a // request whose source is cached holds none of it while it waits for a
// processing slot: past the first bytes, which give the source's format, it // processing slot: it reads the cached file only once it has a slot. With
// reads the cached file only once it has a slot. With the only slot held, a // the only slot held, a request for a new width of the cached 100x100 photo
// request for a new width of the cached 100x100 photo waits; the cached file // waits; the cached file is then rewritten as a 100x50 image before the slot
// is then rewritten as a 100x50 image before the slot is freed, so the // is freed, so the request must answer with that image scaled to 40x20.
// request must answer with that image scaled to 40x20.
func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) { func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
t.Parallel() t.Parallel()
@@ -81,7 +79,7 @@ func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
cachedSource := widthOnlyRequest(fixtures, 50).Source cachedSource := widthOnlyRequest(fixtures, 50).Source
reader, _, _, err := svc.cache.Source(t.Context(), cachedSource) reader, _, err := svc.cache.Source(t.Context(), cachedSource)
if err != nil { if err != nil {
t.Fatalf("Source() error = %v; want the cached source", err) t.Fatalf("Source() error = %v; want the cached source", err)
} }
@@ -55,7 +55,7 @@ func TestProxy_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
reader, _, _, err := svc.cache.Source(t.Context(), source) reader, _, err := svc.cache.Source(t.Context(), source)
if err != nil { if err != nil {
t.Fatalf("Source() error = %v, want the cached source", err) t.Fatalf("Source() error = %v, want the cached source", err)
} }
@@ -7,7 +7,6 @@ import (
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest" "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 // getImage calls Get for req and returns the image it answered with and
@@ -50,7 +49,7 @@ func cachedSourcePath(
) string { ) string {
t.Helper() t.Helper()
reader, _, _, err := svc.cache.Source(t.Context(), req.Source) reader, _, err := svc.cache.Source(t.Context(), req.Source)
if err != nil { if err != nil {
t.Fatalf("Source() error = %v; want the cached source", err) t.Fatalf("Source() error = %v; want the cached source", err)
} }
@@ -105,40 +104,6 @@ func TestProxy_Get_FetchesAgainForRemovedCachedSource(t *testing.T) {
} }
} }
// 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 // TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of
// the photo and removes its row and file, as the reconciliation pass does // 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 // when the file is empty or cut short. internal/imgcache tests that the
@@ -199,7 +199,7 @@ func setupTestProxy(t *testing.T) (*testProxy, *TestFixtures) {
t.Fatalf("failed to create cache: %v", err) t.Fatalf("failed to create cache: %v", err)
} }
proxy, err := New(&Config{ proxy, err := NewProxy(&Config{
Cache: cache, Cache: cache,
Fetcher: httpfetcher.NewMock(mockFS), Fetcher: httpfetcher.NewMock(mockFS),
Processor: imageprocessor.New(imageprocessor.Params{ Processor: imageprocessor.New(imageprocessor.Params{
+4 -30
View File
@@ -1,8 +1,7 @@
// 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, the source key and the
// source's upstream URL. It also holds the image format type and the MIME type // source's upstream URL. It imports no other package of this repo, so every
// of each format. It imports no other package of this repo, so every package // package that handles an image request can import it.
// that handles an image request can import it.
package imagerequest package imagerequest
import ( import (
@@ -13,11 +12,10 @@ import (
// 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. In a request, orig and auto stand for one of them.
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"
@@ -33,30 +31,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 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
@@ -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)
}
}
}
+20 -60
View File
@@ -19,7 +19,6 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
) )
// maxPendingAccessTimes is how many access times of variants served from // maxPendingAccessTimes is how many access times of variants served from
@@ -58,8 +57,8 @@ 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 Config
log *slog.Logger log *slog.Logger
@@ -136,14 +135,14 @@ func NewCache(
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 {
@@ -170,21 +169,14 @@ func (c *Cache) Variant(
return nil, 0, "", ErrNotFound return nil, 0, "", ErrNotFound
} }
file, size, err := c.variants.Open(string(cacheKey)) reader, size, contentType, err := c.variants.LoadWithContentType(cacheKey)
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, "", err
} }
format, err := readFormat(file)
if err != nil {
_ = file.Close()
return nil, 0, "", err
}
c.touchVariant(cacheKey) c.touchVariant(cacheKey)
return file, size, format.MIMEType(), nil return reader, size, contentType, nil
} }
// PutVariant stores a processed variant by its cache key and records // PutVariant stores a processed variant by its cache key and records
@@ -198,18 +190,11 @@ func (c *Cache) PutVariant(
return nil return nil
} }
data, err := io.ReadAll(content) size, err := c.variants.Store(cacheKey, content)
if err != nil {
return fmt.Errorf("failed to read content: %w", err)
}
err = c.variants.Write(string(cacheKey), data)
if err != nil { if err != nil {
return err return err
} }
size := int64(len(data))
_, err = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
INSERT INTO variant_content (cache_key, size_bytes) INSERT INTO variant_content (cache_key, size_bytes)
VALUES (?, ?) VALUES (?, ?)
@@ -227,17 +212,15 @@ func (c *Cache) PutVariant(
return nil return nil
} }
// Source opens the cached source of src and returns a reader, its size in // Source opens the cached source of src and returns a reader and its size
// bytes and its format, read from the file's first bytes. A source whose row // in bytes. A source whose row or file is missing is ErrNotFound, as is any
// or file is missing is ErrNotFound, as is any source of a disabled cache; a // source of a disabled cache. A source it opens has its access time handed
// file whose first bytes are not those of a source format is another error. // to the background writer without waiting (see touchSourceContent).
// A source it opens has its access time handed to the background writer
// without waiting (see touchSourceContent).
func (c *Cache) Source( func (c *Cache) Source(
ctx context.Context, src imagerequest.Source, ctx context.Context, src imagerequest.Source,
) (io.ReadCloser, int64, imagerequest.Format, error) { ) (io.ReadCloser, int64, error) {
if c.config.MaxBytes == 0 { if c.config.MaxBytes == 0 {
return nil, 0, "", ErrNotFound return nil, 0, ErrNotFound
} }
key := src.Key() key := src.Key()
@@ -248,28 +231,21 @@ func (c *Cache) Source(
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?) SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
`, string(key)).Scan(&rowExists) `, string(key)).Scan(&rowExists)
if err != nil { if err != nil {
return nil, 0, "", fmt.Errorf("failed to lookup source: %w", err) return nil, 0, fmt.Errorf("failed to lookup source: %w", err)
} }
if !rowExists { if !rowExists {
return nil, 0, "", ErrNotFound return nil, 0, ErrNotFound
} }
file, size, err := c.srcContent.Open(string(key)) reader, size, err := c.srcContent.LoadWithSize(key)
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, err
}
format, err := readFormat(file)
if err != nil {
_ = file.Close()
return nil, 0, "", err
} }
c.touchSourceContent(key) c.touchSourceContent(key)
return file, size, format, nil return reader, size, nil
} }
// PutSource stores fetched source content: it writes the file named by // PutSource stores fetched source content: it writes the file named by
@@ -294,13 +270,11 @@ func (c *Cache) PutSource(
key := src.Key() key := src.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) INSERT INTO source_content (source_key, size_bytes)
VALUES (?, ?) VALUES (?, ?)
@@ -309,7 +283,7 @@ func (c *Cache) PutSource(
last_accessed_at = CURRENT_TIMESTAMP last_accessed_at = CURRENT_TIMESTAMP
`, string(key), size) `, string(key), size)
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)
@@ -448,17 +422,3 @@ func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
return deleted, nil return deleted, nil
} }
// readFormat reads the 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 six formats.
func readFormat(content io.ReaderAt) (imagerequest.Format, error) {
firstBytes := make([]byte, magic.MinMagicBytes)
_, err := content.ReadAt(firstBytes, 0)
if err != nil {
return "", fmt.Errorf("failed to read the first bytes: %w", err)
}
return magic.DetectFormat(firstBytes)
}
+4 -4
View File
@@ -32,7 +32,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,
@@ -68,7 +68,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)
} }
@@ -96,7 +96,7 @@ func TestVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
}{ }{
{"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>`), {"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
magic.ErrUnknownFormat}, 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},
} }
@@ -104,7 +104,7 @@ 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)
} }
+39 -56
View File
@@ -48,7 +48,7 @@ const staleTempFileAge = time.Hour
const sqliteTimestampLayout = "2006-01-02 15:04:05" const sqliteTimestampLayout = "2006-01-02 15:04:05"
// tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache // tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache
// writes (the ".tmp-*" pattern in writeFileSynced). // writes (".tmp-*" patterns in the storage layer).
const tempFilePrefix = ".tmp-" const tempFilePrefix = ".tmp-"
// UsageBytes returns the total number of bytes of cache content // UsageBytes returns the total number of bytes of cache content
@@ -306,7 +306,7 @@ func (c *Cache) evictVariant(
return fmt.Errorf("failed to delete variant accounting row: %w", err) return fmt.Errorf("failed to delete variant accounting row: %w", err)
} }
err = c.variants.Delete(string(cacheKey)) err = c.variants.Delete(cacheKey)
if err != nil { if err != nil {
return err return err
} }
@@ -325,7 +325,7 @@ func (c *Cache) evictSourceBlob(ctx context.Context, key imagerequest.SourceKey)
return fmt.Errorf("failed to delete source content row: %w", err) return fmt.Errorf("failed to delete source content row: %w", err)
} }
return c.srcContent.Delete(string(key)) return c.srcContent.Delete(key)
} }
// notifyWritePressure wakes the background evictor after a store, so // notifyWritePressure wakes the background evictor after a store, so
@@ -515,15 +515,12 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
} }
// reconcileFiles walks baseDir, one of the cache's storage directories, // reconcileFiles walks baseDir, one of the cache's storage directories,
// sweeping stale temp files and passing each other file, with its info and // sweeping stale temp files and passing each other file, with its name, to
// name, to reconcileFile. It skips a directory it cannot list and a file // reconcileFile. Once ctx is cancelled it stops at the next file and
// whose info it cannot read, so one such entry does not stop the pass. It // returns ctx's error.
// logs a warning naming the path and the error. A file renamed or removed
// after its directory was listed is skipped with no warning. Once ctx is
// cancelled it stops at the next file and returns ctx's error.
func (c *Cache) reconcileFiles( func (c *Cache) reconcileFiles(
ctx context.Context, baseDir string, ctx context.Context, baseDir string,
reconcileFile func(path string, info fs.FileInfo, name string) error, reconcileFile func(path string, entry fs.DirEntry, name string) error,
) error { ) error {
return filepath.WalkDir( return filepath.WalkDir(
baseDir, baseDir,
@@ -532,38 +529,19 @@ func (c *Cache) reconcileFiles(
return ctx.Err() return ctx.Err()
} }
if err != nil { if err != nil || entry.IsDir() {
c.log.Warn("skipped unreadable entry in cache directory", return err
"path", path, "error", err)
return nil
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
c.log.Warn("skipped unreadable entry in cache directory",
"path", path, "error", err)
return nil
} }
name := entry.Name() name := entry.Name()
if strings.HasPrefix(name, tempFilePrefix) { if strings.HasPrefix(name, tempFilePrefix) {
c.sweepStaleTempFile(path, info) c.sweepStaleTempFile(path, entry)
return nil return nil
} }
return reconcileFile(path, info, name) return reconcileFile(path, entry, name)
}, },
) )
} }
@@ -573,8 +551,8 @@ func (c *Cache) reconcileFiles(
// and sweeping stale temp files. // and sweeping stale temp files.
func (c *Cache) reconcileVariantFiles(ctx context.Context) error { func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
return c.reconcileFiles(ctx, c.variants.baseDir, return c.reconcileFiles(ctx, c.variants.baseDir,
func(path string, info fs.FileInfo, name string) error { func(path string, entry fs.DirEntry, name string) error {
return c.reconcileVariantFile(ctx, path, info, imagerequest.VariantKey(name)) return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
}) })
} }
@@ -584,11 +562,16 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
// crash or a restored snapshot can leave it, is removed with its row and // crash or a restored snapshot can leave it, is removed with its row and
// a warning naming it, so the variant is converted again. // a warning naming it, so the variant is converted again.
func (c *Cache) reconcileVariantFile( func (c *Cache) reconcileVariantFile(
ctx context.Context, path string, info fs.FileInfo, cacheKey imagerequest.VariantKey, ctx context.Context, path string, entry fs.DirEntry, cacheKey imagerequest.VariantKey,
) error { ) error {
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat variant file: %w", err)
}
var recordedSize int64 var recordedSize int64
err := c.db.QueryRowContext(ctx, err = c.db.QueryRowContext(ctx,
`SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey), `SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey),
).Scan(&recordedSize) ).Scan(&recordedSize)
@@ -630,9 +613,7 @@ func (c *Cache) adoptVariantFile(
} }
// reconcileVariantRows drops accounting rows whose variant files are // reconcileVariantRows drops accounting rows whose variant files are
// missing, so the database never references deleted content. A file whose // missing, so the database never references deleted content.
// size cannot be read for another reason may still be on disk, so its row
// stays, with a warning naming its key and the error.
func (c *Cache) reconcileVariantRows(ctx context.Context) error { func (c *Cache) reconcileVariantRows(ctx context.Context) error {
var after imagerequest.VariantKey var after imagerequest.VariantKey
@@ -655,19 +636,11 @@ func (c *Cache) reconcileVariantRows(ctx context.Context) error {
return ctx.Err() return ctx.Err()
} }
_, err := c.variants.Size(string(key)) if c.variants.Exists(key) {
if err == nil {
continue continue
} }
if !errors.Is(err, ErrNotFound) { _, err := c.db.ExecContext(ctx,
c.log.Warn("kept accounting row for unreadable variant file",
"cache_key", key, "error", err)
continue
}
_, err = c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(key)) `DELETE FROM variant_content WHERE cache_key = ?`, string(key))
if err != nil { if err != nil {
return fmt.Errorf("failed to drop stale variant accounting row: %w", err) return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
@@ -731,8 +704,8 @@ func queryStringColumn[T ~string](
// and sweeping stale temp files. // and sweeping stale temp files.
func (c *Cache) reconcileSourceFiles(ctx context.Context) error { func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
return c.reconcileFiles(ctx, c.srcContent.baseDir, return c.reconcileFiles(ctx, c.srcContent.baseDir,
func(path string, info fs.FileInfo, name string) error { func(path string, entry fs.DirEntry, name string) error {
return c.reconcileSourceFile(ctx, path, info, imagerequest.SourceKey(name)) return c.reconcileSourceFile(ctx, path, entry, imagerequest.SourceKey(name))
}) })
} }
@@ -743,11 +716,16 @@ func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
// is removed with its row and a warning naming it, so the source is // is removed with its row and a warning naming it, so the source is
// fetched again. // fetched again.
func (c *Cache) reconcileSourceFile( func (c *Cache) reconcileSourceFile(
ctx context.Context, path string, info fs.FileInfo, key imagerequest.SourceKey, ctx context.Context, path string, entry fs.DirEntry, key imagerequest.SourceKey,
) error { ) error {
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat source content file: %w", err)
}
var recordedSize int64 var recordedSize int64
err := c.db.QueryRowContext(ctx, err = c.db.QueryRowContext(ctx,
`SELECT size_bytes FROM source_content WHERE source_key = ?`, `SELECT size_bytes FROM source_content WHERE source_key = ?`,
string(key), string(key),
).Scan(&recordedSize) ).Scan(&recordedSize)
@@ -912,12 +890,17 @@ func (c *Cache) correctUsageTotal(
// sweepStaleTempFile removes a temp file left behind by a crashed // sweepStaleTempFile removes a temp file left behind by a crashed
// write once it is old enough that no in-flight store can own it. // write once it is old enough that no in-flight store can own it.
func (c *Cache) sweepStaleTempFile(path string, info fs.FileInfo) { func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
info, err := entry.Info()
if err != nil {
return
}
if time.Since(info.ModTime()) < staleTempFileAge { if time.Since(info.ModTime()) < staleTempFileAge {
return return
} }
err := os.Remove(path) err = os.Remove(path)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
c.log.Warn("failed to remove stale temp file", "path", path, "error", err) c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
+17 -309
View File
@@ -9,10 +9,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"syscall"
"testing" "testing"
"time" "time"
@@ -211,8 +209,7 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
t.Fatalf("failed to scan source_key: %v", err) t.Fatalf("failed to scan source_key: %v", err)
} }
_, err = os.Stat(cache.srcContent.keyToPath(key)) if !cache.srcContent.Exists(imagerequest.SourceKey(key)) {
if err != nil {
t.Errorf("source_content references content %s but the file is missing", t.Errorf("source_content references content %s but the file is missing",
key) key)
} }
@@ -239,8 +236,7 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
t.Fatalf("failed to scan cache_key: %v", err) t.Fatalf("failed to scan cache_key: %v", err)
} }
_, err = os.Stat(cache.variants.keyToPath(key)) if !cache.variants.Exists(imagerequest.VariantKey(key)) {
if err != nil {
t.Errorf("variant_content references key %s but the file is missing", key) t.Errorf("variant_content references key %s but the file is missing", key)
} }
} }
@@ -338,8 +334,7 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
t.Errorf("usage after eviction = %d, want <= %d", usage, limit) t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
} }
_, err = os.Stat(cache.variants.keyToPath(string(keys[0]))) if cache.variants.Exists(keys[0]) {
if err == nil {
t.Errorf("least recently used variant %s must be evicted", keys[0]) t.Errorf("least recently used variant %s must be evicted", keys[0])
} }
@@ -350,8 +345,7 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
} }
for _, key := range keys[1:] { for _, key := range keys[1:] {
_, err = os.Stat(cache.variants.keyToPath(string(key))) if !cache.variants.Exists(key) {
if err != nil {
t.Errorf("more recently used variant %s must survive eviction", key) t.Errorf("more recently used variant %s must survive eviction", key)
} }
} }
@@ -379,8 +373,7 @@ func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
} }
for _, key := range keys { for _, key := range keys {
_, err = os.Stat(cache.srcContent.keyToPath(string(key))) if !cache.srcContent.Exists(key) {
if err != nil {
t.Errorf("source %s must not be evicted while usage is under the limit", key) t.Errorf("source %s must not be evicted while usage is under the limit", key)
} }
@@ -391,8 +384,7 @@ func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
} }
} }
_, err = os.Stat(cache.variants.keyToPath(string(testVariantKeyOne))) if !cache.variants.Exists(testVariantKeyOne) {
if err != nil {
t.Error("variant must not be evicted while usage is under the limit") t.Error("variant must not be evicted while usage is under the limit")
} }
@@ -457,7 +449,7 @@ func assertDisabledCacheReadsAlwaysMiss(
ctx := t.Context() ctx := t.Context()
reader, _, _, err := cache.Source(ctx, req.Source) reader, _, err := cache.Source(ctx, req.Source)
if !errors.Is(err, ErrNotFound) { if !errors.Is(err, ErrNotFound) {
t.Fatalf("Source on disabled cache error = %v, want %v", err, ErrNotFound) t.Fatalf("Source on disabled cache error = %v, want %v", err, ErrNotFound)
} }
@@ -594,7 +586,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
for i, key := range keys { for i, key := range keys {
content := bytes.Repeat([]byte{fills[i]}, 1000) content := bytes.Repeat([]byte{fills[i]}, 1000)
err = cache.variants.Write(string(key), content) _, err = cache.variants.Store(key, bytes.NewReader(content))
if err != nil { if err != nil {
t.Fatalf("failed to store variant file: %v", err) t.Fatalf("failed to store variant file: %v", err)
} }
@@ -613,8 +605,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
stored = 0 stored = 0
for _, key := range keys { for _, key := range keys {
_, err = os.Stat(cache.variants.keyToPath(string(key))) if cache.variants.Exists(key) {
if err == nil {
stored++ stored++
} }
} }
@@ -646,7 +637,9 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
// feature existed) must be adopted into the accounting. // feature existed) must be adopted into the accounting.
untracked := bytes.Repeat([]byte{0x31}, 1000) untracked := bytes.Repeat([]byte{0x31}, 1000)
err := cache.variants.Write(string(testVariantKeyOne), untracked) _, err := cache.variants.Store(
testVariantKeyOne, bytes.NewReader(untracked),
)
if err != nil { if err != nil {
t.Fatalf("failed to store untracked variant file: %v", err) t.Fatalf("failed to store untracked variant file: %v", err)
} }
@@ -701,291 +694,6 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
} }
} }
// logLineNaming returns the first line of logs, written by slog's JSON
// handler, that is at level and names the variant key, or "" if none is.
func logLineNaming(logs, level string, key imagerequest.VariantKey) string {
for line := range strings.Lines(logs) {
if strings.Contains(line, `"level":"`+level+`"`) &&
strings.Contains(line, `"cache_key":"`+string(key)+`"`) {
return line
}
}
return ""
}
// TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles gives
// reconcileVariantRows two tracked variants. The first one's file is a
// symbolic link to itself, so its size cannot be read. Its row must stay,
// with a warning naming its key and the error. The second one has no file.
// Its row must go, with the info line for a missing file.
func TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestVariant(t, cache, testVariantKeyOne, []byte("variant one"))
storeEvictionTestVariant(t, cache, testVariantKeyTwo, []byte("variant two"))
unreadablePath := cache.variants.keyToPath(string(testVariantKeyOne))
err := os.Remove(unreadablePath)
if err != nil {
t.Fatalf("failed to remove the first variant's file: %v", err)
}
err = os.Symlink(unreadablePath, unreadablePath)
if err != nil {
t.Fatalf("failed to link the first variant's path to itself: %v", err)
}
_, statErr := os.Stat(unreadablePath)
if statErr == nil || errors.Is(statErr, fs.ErrNotExist) {
t.Fatalf("os.Stat() of the first variant: error = %v, "+
"want an error other than a missing file", statErr)
}
err = os.Remove(cache.variants.keyToPath(string(testVariantKeyTwo)))
if err != nil {
t.Fatalf("failed to remove the second variant's file: %v", err)
}
var logBuf bytes.Buffer
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
err = cache.reconcileVariantRows(t.Context())
t.Logf("reconcileVariantRows() error = %v, log: %s", err, logBuf.String())
if err != nil {
t.Fatalf("reconcileVariantRows() error = %v", err)
}
rowQuery := `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`
if n := countRows(t, cache, rowQuery, string(testVariantKeyOne)); n != 1 {
t.Errorf("the variant whose size cannot be read has %d rows, want 1", n)
}
if n := countRows(t, cache, rowQuery, string(testVariantKeyTwo)); n != 0 {
t.Errorf("the variant with no file has %d rows, want 0", n)
}
logs := logBuf.String()
warning := logLineNaming(logs, "WARN", testVariantKeyOne)
if !strings.Contains(warning, statErr.Error()) {
t.Errorf("no warning names %s and the error %q", testVariantKeyOne, statErr)
}
if line := logLineNaming(logs, "INFO", testVariantKeyOne); line != "" {
t.Errorf("the variant whose size cannot be read was logged at info: %s", line)
}
dropped := logLineNaming(logs, "INFO", testVariantKeyTwo)
if !strings.Contains(dropped, "dropped accounting row for missing variant file") {
t.Errorf("no info line says the file of %s is missing", testVariantKeyTwo)
}
}
// openDirectoryNearPathLimit makes a chain of directories inside dir, each
// named with 255 zeros, the longest name Linux allows. Linux refuses a path
// of 4096 bytes or more, and the chain goes as deep as it can below that.
// It returns the chain's deepest directory as an os.Root, closed when the
// test ends. An entry made through it with a 255-byte name has a path even
// the root user cannot read. The os.Root can make that entry because it
// names the entry only by its name, not by the whole path.
func openDirectoryNearPathLimit(t *testing.T, dir string) *os.Root {
t.Helper()
name := strings.Repeat("0", 255)
deepest := dir
for len(filepath.Join(deepest, name)) < 4096 {
deepest = filepath.Join(deepest, name)
}
err := os.MkdirAll(deepest, StorageDirPerm)
if err != nil {
t.Fatalf("failed to make a chain of directories: %v", err)
}
root, err := os.OpenRoot(deepest)
if err != nil {
t.Fatalf("os.OpenRoot(%s) error = %v", deepest, err)
}
t.Cleanup(func() { _ = root.Close() })
return root
}
// makeEntriesTooLongToRead makes a new directory and a new file in the
// deepest directory of the chain that openDirectoryNearPathLimit makes
// inside dir. Even the root user can then neither list the new directory
// nor read the new file's info. It returns the paths of the new directory
// and the new file.
func makeEntriesTooLongToRead(t *testing.T, dir string) []string {
t.Helper()
deepest := openDirectoryNearPathLimit(t, dir)
dirName := strings.Repeat("0", 255)
err := deepest.Mkdir(dirName, StorageDirPerm)
if err != nil {
t.Fatalf("failed to make a directory too deep to list: %v", err)
}
fileName := strings.Repeat("1", 255)
err = deepest.WriteFile(fileName, []byte("unreadable"), StorageFilePerm)
if err != nil {
t.Fatalf("failed to make a file too deep to read: %v", err)
}
return []string{
filepath.Join(deepest.Name(), dirName),
filepath.Join(deepest.Name(), fileName),
}
}
// makeTempFileTooLongToRead makes a temp file in the deepest directory of
// the chain that openDirectoryNearPathLimit makes inside dir. Even the
// root user then cannot read the temp file's info. It returns the temp
// file's path.
func makeTempFileTooLongToRead(t *testing.T, dir string) string {
t.Helper()
deepest := openDirectoryNearPathLimit(t, dir)
name := tempFilePrefix + strings.Repeat("1", 255-len(tempFilePrefix))
err := deepest.WriteFile(name, []byte("partial"), StorageFilePerm)
if err != nil {
t.Fatalf("failed to make a temp file too deep to read: %v", err)
}
return filepath.Join(deepest.Name(), name)
}
// assertSkippedEntryWarning checks that logs hold the warning "skipped
// unreadable entry in cache directory" naming path, with the error
// ENAMETOOLONG for that path.
func assertSkippedEntryWarning(t *testing.T, logs, path string) {
t.Helper()
for line := range strings.Lines(logs) {
if strings.Contains(line, `"level":"WARN"`) &&
strings.Contains(line, `"msg":"skipped unreadable entry in cache directory"`) &&
strings.Contains(line, `"path":"`+path+`"`) &&
strings.Contains(line, path+": "+syscall.ENAMETOOLONG.Error()) {
return
}
}
t.Errorf("no warning %q names %s with the error %q",
"skipped unreadable entry in cache directory", path, syscall.ENAMETOOLONG)
}
// TestReconciliationGoesOnPastEntriesItCannotRead puts, in each storage
// directory, a directory the pass cannot list and a file whose info it
// cannot read. The pass walks each directory in name order, so it reaches
// them before the cache's files, whose keys start with a letter. It must
// log a warning naming each one's path and the error, and still do the
// rest of its work:
// - adopt a variant file that has no row
// - drop the row of a variant whose file is missing
// - remove a source file that has no row
// - correct a wrong total cache usage
func TestReconciliationGoesOnPastEntriesItCannotRead(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
ctx := t.Context()
untrackedVariant := []byte("variant with no row")
err := cache.variants.Write(string(testVariantKeyOne), untrackedVariant)
if err != nil {
t.Fatalf("failed to write a variant file with no row: %v", err)
}
storeEvictionTestVariant(t, cache, testVariantKeyTwo, []byte("variant two"))
err = os.Remove(cache.variants.keyToPath(string(testVariantKeyTwo)))
if err != nil {
t.Fatalf("failed to remove the second variant's file: %v", err)
}
untrackedSourceKey := "ffeeddcc0001"
err = cache.srcContent.Write(untrackedSourceKey, []byte("source with no row"))
if err != nil {
t.Fatalf("failed to write a source file with no row: %v", err)
}
_, err = cache.db.ExecContext(ctx,
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
if err != nil {
t.Fatalf("failed to set a wrong total: %v", err)
}
unreadable := slices.Concat(
makeEntriesTooLongToRead(t, cache.variants.baseDir),
makeEntriesTooLongToRead(t, cache.srcContent.baseDir),
)
logs := reconcileAsAtStartup(t, cache)
for _, path := range unreadable {
assertSkippedEntryWarning(t, logs, path)
}
rowQuery := `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`
if n := countRows(t, cache, rowQuery, string(testVariantKeyOne)); n != 1 {
t.Errorf("the variant file with no row got %d rows, want 1", n)
}
if n := countRows(t, cache, rowQuery, string(testVariantKeyTwo)); n != 0 {
t.Errorf("the variant with no file kept %d rows, want 0", n)
}
_, err = os.Stat(cache.srcContent.keyToPath(untrackedSourceKey))
if err == nil {
t.Error("the source file with no row is still on disk")
}
usage, err := cache.UsageBytes(ctx)
if err != nil {
t.Fatalf("UsageBytes() error = %v", err)
}
if usage != int64(len(untrackedVariant)) {
t.Errorf("UsageBytes() = %d, want %d, the size of the adopted variant",
usage, len(untrackedVariant))
}
}
// TestReconciliationWarnsOfTempFilesWhoseInfoItCannotRead puts, in each
// storage directory, a temp file whose info the pass cannot read. The
// pass must log a warning naming each one's path and the error, as it
// does for any other file whose info it cannot read.
func TestReconciliationWarnsOfTempFilesWhoseInfoItCannotRead(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
unreadable := []string{
makeTempFileTooLongToRead(t, cache.variants.baseDir),
makeTempFileTooLongToRead(t, cache.srcContent.baseDir),
}
logs := reconcileAsAtStartup(t, cache)
for _, path := range unreadable {
assertSkippedEntryWarning(t, logs, path)
}
}
// TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves // TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves
// reconciliation is not a one-shot startup-only pass: it must also run // reconciliation is not a one-shot startup-only pass: it must also run
// on the periodic ticker, so a variant file that lands on disk with no // on the periodic ticker, so a variant file that lands on disk with no
@@ -1030,7 +738,9 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
// insert had failed and only the file write had succeeded. // insert had failed and only the file write had succeeded.
untracked := bytes.Repeat([]byte{0x41}, 900) untracked := bytes.Repeat([]byte{0x41}, 900)
err = cache.variants.Write("aabbccdd0099", untracked) _, err = cache.variants.Store(
"aabbccdd0099", bytes.NewReader(untracked),
)
if err != nil { if err != nil {
t.Fatalf("failed to store untracked variant file: %v", err) t.Fatalf("failed to store untracked variant file: %v", err)
} }
@@ -1336,15 +1046,13 @@ func assertOnlyOldestSourceEvicted(
) { ) {
t.Helper() t.Helper()
_, err := os.Stat(cache.srcContent.keyToPath(string(keys[0]))) if cache.srcContent.Exists(keys[0]) {
if err == nil {
t.Errorf("source %s, evicted when the context was cancelled, "+ t.Errorf("source %s, evicted when the context was cancelled, "+
"is still on disk", keys[0]) "is still on disk", keys[0])
} }
for _, key := range keys[1:] { for _, key := range keys[1:] {
_, err = os.Stat(cache.srcContent.keyToPath(string(key))) if !cache.srcContent.Exists(key) {
if err != nil {
t.Errorf("source %s was evicted after the context was cancelled", key) t.Errorf("source %s was evicted after the context was cancelled", key)
} }
-185
View File
@@ -1,185 +0,0 @@
package imgcache
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// Storage constants.
const (
// StorageDirPerm is the permission mode for storage directories.
StorageDirPerm = 0750
// StorageFilePerm is the permission mode for storage files.
StorageFilePerm = 0600
// MinHashLength is the minimum hash length for path splitting.
MinHashLength = 4
)
// Storage errors.
var (
ErrNotFound = errors.New("content not found")
)
// FileStore is a directory of files, each named by a hex key. The file for
// a key is at <basedir>/<ab>/<cd>/<key>, where <ab> and <cd> are the key's
// first and second pairs of characters. The cache keeps its source images in
// one FileStore and its variants in another.
type FileStore struct {
baseDir string
}
// NewFileStore creates the directory baseDir if it is missing and returns
// the FileStore of the files in it.
func NewFileStore(baseDir string) (*FileStore, error) {
err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil {
return nil, fmt.Errorf("failed to create storage directory: %w", err)
}
return &FileStore{baseDir: baseDir}, nil
}
// Write writes data with writeFileSynced to the file for key, replacing any
// file already there.
func (s *FileStore) Write(key string, data []byte) error {
path := s.keyToPath(key)
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
if err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
return writeFileSynced(path, data)
}
// Open opens the file for key and returns it with its size in bytes. A
// missing file is ErrNotFound.
func (s *FileStore) Open(key string) (*os.File, int64, error) {
f, err := os.Open(s.keyToPath(key))
if err != nil {
if os.IsNotExist(err) {
return nil, 0, ErrNotFound
}
return nil, 0, fmt.Errorf("failed to open content: %w", err)
}
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
}
return f, stat.Size(), nil
}
// Size returns the size in bytes of the file for key. A missing file is
// ErrNotFound.
func (s *FileStore) Size(key string) (int64, error) {
stat, err := os.Stat(s.keyToPath(key))
if err != nil {
if os.IsNotExist(err) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("failed to stat content: %w", err)
}
return stat.Size(), nil
}
// Delete removes the file for key. A missing file is not an error.
func (s *FileStore) Delete(key string) error {
err := os.Remove(s.keyToPath(key))
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete content: %w", err)
}
return nil
}
// writeFileSynced writes data to path through a temp file in the same
// directory: the temp file is synced to disk, renamed to path, and the
// directory synced after, so that neither a concurrent reader nor a
// crash finds a partial file under path. When the directory sync fails,
// the file is removed from path, so that a failed write leaves no file
// there.
func writeFileSynced(path string, data []byte) error {
dir := filepath.Dir(path)
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
_, err = tmpFile.Write(data)
if err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
err = tmpFile.Sync()
if err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
err = tmpFile.Close()
if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
err = syncDirectory(dir)
if err != nil {
_ = os.Remove(path)
return err
}
return nil
}
// syncDirectory syncs the directory dir to disk, so that a file just
// renamed into it is still there after a crash.
func syncDirectory(dir string) error {
d, err := os.Open(filepath.Clean(dir))
if err != nil {
return fmt.Errorf("failed to open directory: %w", err)
}
err = d.Sync()
_ = d.Close()
if err != nil {
return fmt.Errorf("failed to sync directory: %w", err)
}
return nil
}
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
func (s *FileStore) keyToPath(key string) string {
if len(key) < MinHashLength {
return filepath.Join(s.baseDir, key)
}
return filepath.Join(s.baseDir, key[0:2], key[2:4], key)
}
@@ -1,184 +0,0 @@
package imgcache
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
func TestFileStore_WriteAndOpen(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewFileStore(tmpDir)
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
content := []byte("hello world")
key := string(imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key())
err = storage.Write(key, content)
if err != nil {
t.Fatalf("Write() error = %v", err)
}
// Verify file exists at expected path
expectedPath := filepath.Join(tmpDir, key[0:2], key[2:4], key)
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err)
}
// Open and verify size and content
r, size, err := storage.Open(key)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
defer func() { _ = r.Close() }()
if size != int64(len(content)) {
t.Errorf("Open() size = %d, want %d", size, len(content))
}
loaded, err := io.ReadAll(r)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if !bytes.Equal(loaded, content) {
t.Errorf("Open() content = %q, want %q", loaded, content)
}
}
func TestFileStore_OpenNotFound(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewFileStore(tmpDir)
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
_, _, err = storage.Open("nonexistent")
if !errors.Is(err, ErrNotFound) {
t.Errorf("Open() error = %v, want ErrNotFound", err)
}
}
// TestFileStore_Size checks that Size returns the size of a written file,
// and ErrNotFound for a key with no file.
func TestFileStore_Size(t *testing.T) {
t.Parallel()
storage, err := NewFileStore(t.TempDir())
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
content := []byte("the file's content")
key := string(imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key())
err = storage.Write(key, content)
if err != nil {
t.Fatalf("Write() error = %v", err)
}
size, err := storage.Size(key)
if err != nil || size != int64(len(content)) {
t.Errorf("Size() = %d, error %v; want %d", size, err, len(content))
}
_, err = storage.Size("nonexistent")
if !errors.Is(err, ErrNotFound) {
t.Errorf("Size() of a missing file error = %v, want ErrNotFound", err)
}
}
func TestFileStore_Delete(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewFileStore(tmpDir)
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
content := []byte("to be deleted")
key := string(imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key())
path := storage.keyToPath(key)
err = storage.Write(key, content)
if err != nil {
t.Fatalf("Write() error = %v", err)
}
_, err = os.Stat(path)
if err != nil {
t.Errorf("file missing before Delete(): %v", err)
}
err = storage.Delete(key)
if err != nil {
t.Fatalf("Delete() error = %v", err)
}
_, err = os.Stat(path)
if err == nil {
t.Error("file still on disk after Delete()")
}
}
func TestFileStore_DeleteNonexistent(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewFileStore(tmpDir)
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
// Should not error
err = storage.Delete("nonexistent")
if err != nil {
t.Errorf("Delete() error = %v, want nil", err)
}
}
func TestFileStore_KeyToPath(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewFileStore(tmpDir)
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
// Test by writing and verifying the resulting path structure
content := []byte("test content for path verification")
key := string(imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key())
err = storage.Write(key, content)
if err != nil {
t.Fatalf("Write() error = %v", err)
}
expectedPath := filepath.Join(tmpDir, key[0:2], key[2:4], key)
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err)
}
}
@@ -67,8 +67,8 @@ func TestReconciliationRemovesDamagedFiles(t *testing.T) {
sourceKey := storeEvictionTestSource(t, cache, testHostCDN, testPathCat, content) sourceKey := storeEvictionTestSource(t, cache, testHostCDN, testPathCat, content)
storeEvictionTestVariant(t, cache, testVariantKeyOne, content) storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
sourcePath := cache.srcContent.keyToPath(string(sourceKey)) sourcePath := cache.srcContent.keyToPath(sourceKey)
variantPath := cache.variants.keyToPath(string(testVariantKeyOne)) variantPath := cache.variants.keyToPath(testVariantKeyOne)
for _, path := range []string{sourcePath, variantPath} { for _, path := range []string{sourcePath, variantPath} {
err := os.Truncate(path, size) err := os.Truncate(path, size)
@@ -82,13 +82,11 @@ func TestReconciliationRemovesDamagedFiles(t *testing.T) {
assertWarningNames(t, logs, sourcePath) assertWarningNames(t, logs, sourcePath)
assertWarningNames(t, logs, variantPath) assertWarningNames(t, logs, variantPath)
_, err := os.Stat(sourcePath) if cache.srcContent.Exists(sourceKey) {
if err == nil {
t.Error("the damaged source file is still on disk") t.Error("the damaged source file is still on disk")
} }
_, err = os.Stat(variantPath) if cache.variants.Exists(testVariantKeyOne) {
if err == nil {
t.Error("the damaged variant file is still on disk") t.Error("the damaged variant file is still on disk")
} }
@@ -118,19 +116,16 @@ func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
cache, _ := newEvictionTestCache(t, 1<<30) cache, _ := newEvictionTestCache(t, 1<<30)
err := cache.variants.Write(string(testVariantKeyOne), nil) _, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil))
if err != nil { if err != nil {
t.Fatalf("failed to store an empty variant file: %v", err) t.Fatalf("failed to store an empty variant file: %v", err)
} }
variantPath := cache.variants.keyToPath(string(testVariantKeyOne))
logs := reconcileAsAtStartup(t, cache) logs := reconcileAsAtStartup(t, cache)
assertWarningNames(t, logs, variantPath) assertWarningNames(t, logs, cache.variants.keyToPath(testVariantKeyOne))
_, err = os.Stat(variantPath) if cache.variants.Exists(testVariantKeyOne) {
if err == nil {
t.Error("the empty variant file is still on disk") t.Error("the empty variant file is still on disk")
} }
@@ -142,19 +137,19 @@ func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
} }
} }
// TestFileStore_WriteReplacesFileOfAnotherSize checks that a write over a // TestContentStorage_StoreReplacesFileOfAnotherSize checks that a
// file of another size at the source's path, such as a partial file a crash // store over a file of another size at the source's path, such as a
// left, replaces it with the content. // partial file a crash left, replaces it with the content.
func TestFileStore_WriteReplacesFileOfAnotherSize(t *testing.T) { func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
t.Parallel() t.Parallel()
storage, err := NewFileStore(t.TempDir()) storage, err := NewContentStorage(t.TempDir())
if err != nil { if err != nil {
t.Fatalf("NewFileStore() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
} }
content := []byte("the whole source image") content := []byte("the whole source image")
key := string(imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()) key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
path := storage.keyToPath(key) path := storage.keyToPath(key)
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm) err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
@@ -167,14 +162,14 @@ func TestFileStore_WriteReplacesFileOfAnotherSize(t *testing.T) {
t.Fatalf("writing a partial file: %v", err) t.Fatalf("writing a partial file: %v", err)
} }
err = storage.Write(key, content) _, err = storage.Store(key, content)
if err != nil { if err != nil {
t.Fatalf("Write() error = %v", err) t.Fatalf("Store() error = %v", err)
} }
stored, _, err := storage.Open(key) stored, _, err := storage.LoadWithSize(key)
if err != nil { if err != nil {
t.Fatalf("Open() error = %v", err) t.Fatalf("LoadWithSize() error = %v", err)
} }
defer func() { _ = stored.Close() }() defer func() { _ = stored.Close() }()
@@ -5,7 +5,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"image/color"
"io" "io"
"log/slog" "log/slog"
"strings" "strings"
@@ -205,8 +204,8 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
t.Parallel() t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30) cache, _ := newEvictionTestCache(t, 1<<30)
content := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255}) key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg",
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg", content) []byte("source bytes"))
anHourAgo := time.Now().Add(-time.Hour) anHourAgo := time.Now().Add(-time.Hour)
setSourceLastAccessed(t, cache, key, anHourAgo) setSourceLastAccessed(t, cache, key, anHourAgo)
@@ -216,7 +215,7 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
` `
anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat) anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat)
reader, _, _, err := cache.Source(t.Context(), reader, _, err := cache.Source(t.Context(),
imagerequest.Source{Host: "example.com", Path: "/photo.jpg"}) imagerequest.Source{Host: "example.com", Path: "/photo.jpg"})
if err != nil { if err != nil {
t.Fatalf("Source() error = %v; want the source of %q", err, key) t.Fatalf("Source() error = %v; want the source of %q", err, key)
@@ -225,7 +224,7 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
found, err := io.ReadAll(reader) found, err := io.ReadAll(reader)
_ = reader.Close() _ = reader.Close()
if err != nil || !bytes.Equal(found, content) { if err != nil || string(found) != "source bytes" {
t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key) t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key)
} }
@@ -2,17 +2,12 @@ package imgcache
import ( import (
"bytes" "bytes"
"errors"
"fmt"
"image/color"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
) )
// TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes // TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes
@@ -69,7 +64,7 @@ func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
t.Parallel() t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30) cache, _ := newEvictionTestCache(t, 1<<30)
content := generateTestJPEG(t, 20, 20, color.RGBA{0x5B, 0x5B, 0x5B, 255}) content := bytes.Repeat([]byte{0x5B}, 400)
evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content) evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content)
storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content) storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content)
@@ -79,8 +74,7 @@ func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
t.Fatalf("evictSourceBlob() error = %v", err) t.Fatalf("evictSourceBlob() error = %v", err)
} }
_, err = os.Stat(cache.srcContent.keyToPath(string(evicted))) if cache.srcContent.Exists(evicted) {
if err == nil {
t.Error("the evicted source's file is still on disk") t.Error("the evicted source's file is still on disk")
} }
@@ -90,7 +84,7 @@ func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
t.Errorf("the evicted source still has %d source_content rows", n) t.Errorf("the evicted source still has %d source_content rows", n)
} }
reader, _, _, err := cache.Source(t.Context(), reader, _, err := cache.Source(t.Context(),
imagerequest.Source{Host: testHostCDN, Path: "/kept.jpg"}) imagerequest.Source{Host: testHostCDN, Path: "/kept.jpg"})
t.Logf("Source() of the kept source: error %v", err) t.Logf("Source() of the kept source: error %v", err)
@@ -135,104 +129,7 @@ func TestPutSource_FailedRowWriteLeavesNoFile(t *testing.T) {
t.Error("PutSource() error = nil, want the failed row write") t.Error("PutSource() error = nil, want the failed row write")
} }
_, err = os.Stat(cache.srcContent.keyToPath(string(req.Source.Key()))) if cache.srcContent.Exists(req.Source.Key()) {
if err == nil {
t.Error("the source's file is on disk although its row was not written") t.Error("the source's file is on disk although its row was not written")
} }
} }
// TestSource_FormatFromFirstBytes stores an image in each source format
// under one source URL, in turn. After each store, Source must return that
// image whole, with its format read from the file.
func TestSource_FormatFromFirstBytes(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
processor := imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
})
jpegImage := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
src := imagerequest.Source{Host: testHostCDN, Path: testPathCat}
for _, format := range []imagerequest.Format{
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
} {
result, err := processor.Process(t.Context(), bytes.NewReader(jpegImage),
imagerequest.FormatJPEG, &imagerequest.Transform{Format: format})
if err != nil {
t.Fatalf("making a %s image: %v", format, err)
}
image, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("reading the %s image: %v", format, err)
}
_, err = cache.PutSource(t.Context(), src, bytes.NewReader(image))
if err != nil {
t.Fatalf("PutSource(%s) error = %v", format, err)
}
reader, size, got, err := cache.Source(t.Context(), src)
if err != nil {
t.Fatalf("Source() of the %s image error = %v", format, err)
}
served, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil {
t.Fatalf("reading the %s source: %v", format, err)
}
t.Logf("%s: %d bytes read as %s", format, size, got)
if got != format {
t.Errorf("Source() of the %s image format = %q, want %q",
format, got, format)
}
if size != int64(len(image)) || !bytes.Equal(served, image) {
t.Errorf("%s source read as %d bytes, size %d, want the %d bytes stored",
format, len(served), size, len(image))
}
}
}
// TestSource_RefusesFileOfNoSourceFormat checks that Source refuses a
// cached source whose file's first bytes are not those of a source format,
// with an error other than ErrNotFound.
func TestSource_RefusesFileOfNoSourceFormat(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
tests := []struct {
name string
content []byte
wantErr error
}{
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
magic.ErrUnknownFormat},
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
{"empty file", nil, io.EOF},
}
for i, tt := range tests {
path := fmt.Sprintf("/refused%d.jpg", i)
storeEvictionTestSource(t, cache, testHostCDN, path, tt.content)
reader, _, format, err := cache.Source(t.Context(),
imagerequest.Source{Host: testHostCDN, Path: path})
t.Logf("%s: Source() error = %v", tt.name, err)
if reader != nil || format != "" || !errors.Is(err, tt.wantErr) {
t.Errorf("%s: Source() = reader %v, format %q, error %v; "+
"want no reader and %v", tt.name, reader, format, err, tt.wantErr)
}
}
}
+326
View File
@@ -0,0 +1,326 @@
package imgcache
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
)
// Storage constants.
const (
// StorageDirPerm is the permission mode for storage directories.
StorageDirPerm = 0750
// StorageFilePerm is the permission mode for storage files.
StorageFilePerm = 0600
// MinHashLength is the minimum hash length for path splitting.
MinHashLength = 4
)
// Storage errors.
var (
ErrNotFound = errors.New("content not found")
)
// ContentStorage handles source image file storage, one file per source
// URL. Files are stored at: <basedir>/<ab>/<cd>/<source key>
type ContentStorage struct {
baseDir string
}
// NewContentStorage creates a new content storage at the given base directory.
func NewContentStorage(baseDir string) (*ContentStorage, error) {
err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil {
return nil, fmt.Errorf("failed to create storage directory: %w", err)
}
return &ContentStorage{baseDir: baseDir}, nil
}
// Store writes data with writeFileSynced to the path derived from key,
// replacing any file already there, and returns its size.
func (s *ContentStorage) Store(
key imagerequest.SourceKey, data []byte,
) (int64, error) {
path := s.keyToPath(key)
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
if err != nil {
return 0, fmt.Errorf("failed to create directory: %w", err)
}
err = writeFileSynced(path, data)
if err != nil {
return 0, err
}
return int64(len(data)), nil
}
// LoadWithSize returns a reader and file size for the content with the given key.
func (s *ContentStorage) LoadWithSize(
key imagerequest.SourceKey,
) (io.ReadCloser, int64, error) {
path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from source key
if err != nil {
if os.IsNotExist(err) {
return nil, 0, ErrNotFound
}
return nil, 0, fmt.Errorf("failed to open content: %w", err)
}
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
}
return f, stat.Size(), nil
}
// Delete removes content with the given key.
func (s *ContentStorage) Delete(key imagerequest.SourceKey) error {
path := s.keyToPath(key)
err := os.Remove(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete content: %w", err)
}
return nil
}
// Exists checks if content with the given key exists.
func (s *ContentStorage) Exists(key imagerequest.SourceKey) bool {
path := s.keyToPath(key)
_, err := os.Stat(path)
return err == nil
}
// writeFileSynced writes data to path through a temp file in the same
// directory: the temp file is synced to disk, renamed to path, and the
// directory synced after, so that neither a concurrent reader nor a
// crash finds a partial file under path. When the directory sync fails,
// the file is removed from path, so that a failed write leaves no file
// there.
func writeFileSynced(path string, data []byte) error {
dir := filepath.Dir(path)
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
_, err = tmpFile.Write(data)
if err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
err = tmpFile.Sync()
if err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
err = tmpFile.Close()
if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
err = syncDirectory(dir)
if err != nil {
_ = os.Remove(path)
return err
}
return nil
}
// syncDirectory syncs the directory dir to disk, so that a file just
// renamed into it is still there after a crash.
func syncDirectory(dir string) error {
d, err := os.Open(filepath.Clean(dir))
if err != nil {
return fmt.Errorf("failed to open directory: %w", err)
}
err = d.Sync()
_ = d.Close()
if err != nil {
return fmt.Errorf("failed to sync directory: %w", err)
}
return nil
}
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
func (s *ContentStorage) keyToPath(key imagerequest.SourceKey) string {
k := string(key)
if len(k) < MinHashLength {
return filepath.Clean(filepath.Join(s.baseDir, k))
}
return filepath.Clean(filepath.Join(s.baseDir, k[0:2], k[2:4], k))
}
// VariantStorage handles key-based file storage for processed image variants.
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
type VariantStorage struct {
baseDir string
}
// errNotAnOutputFormat is returned for a variant file whose first bytes are
// those of a format pixa reads but never writes, such as SVG.
var errNotAnOutputFormat = errors.New("variant file is not in an output format")
// NewVariantStorage creates a new variant storage at the given base directory.
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil {
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
}
return &VariantStorage{baseDir: baseDir}, nil
}
// Store writes content to storage at the given key with writeFileSynced.
func (s *VariantStorage) Store(
key imagerequest.VariantKey, r io.Reader,
) (int64, error) {
data, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("failed to read content: %w", err)
}
path := s.keyToPath(key)
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
if err != nil {
return 0, fmt.Errorf("failed to create directory: %w", err)
}
err = writeFileSynced(path, data)
if err != nil {
return 0, err
}
return int64(len(data)), nil
}
// LoadWithContentType opens the content at the given key and returns a
// reader, the file size and the content type, which is read from the
// file's first bytes. A missing file is ErrNotFound. A file whose first
// bytes are not those of an output format, an empty one included, is
// closed again and the reason returned.
func (s *VariantStorage) LoadWithContentType(
key imagerequest.VariantKey,
) (io.ReadCloser, int64, string, error) {
path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from cache key
if err != nil {
if os.IsNotExist(err) {
return nil, 0, "", ErrNotFound
}
return nil, 0, "", fmt.Errorf("failed to open content: %w", err)
}
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, "", fmt.Errorf("failed to stat content: %w", err)
}
contentType, err := outputContentType(f)
if err != nil {
_ = f.Close()
return nil, 0, "", err
}
return f, stat.Size(), contentType, nil
}
// outputContentType reads the first bytes of content, without moving its
// offset, and returns the content type of the output format they show.
func outputContentType(content io.ReaderAt) (string, error) {
firstBytes := make([]byte, magic.MinMagicBytes)
_, err := content.ReadAt(firstBytes, 0)
if err != nil {
return "", fmt.Errorf("failed to read the first bytes: %w", err)
}
detected, err := magic.DetectFormat(firstBytes)
if err != nil {
return "", err
}
_, isOutputFormat := magic.MIMEToImageFormat(string(detected))
if !isOutputFormat {
return "", fmt.Errorf("%w: %s", errNotAnOutputFormat, detected)
}
return string(detected), nil
}
// Exists checks if content exists at the given key.
func (s *VariantStorage) Exists(key imagerequest.VariantKey) bool {
path := s.keyToPath(key)
_, err := os.Stat(path)
return err == nil
}
// Delete removes the content at the given key. A missing file is not an
// error.
func (s *VariantStorage) Delete(key imagerequest.VariantKey) error {
path := s.keyToPath(key)
err := os.Remove(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete content: %w", err)
}
return nil
}
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
func (s *VariantStorage) keyToPath(key imagerequest.VariantKey) string {
k := string(key)
if len(k) < MinHashLength {
return filepath.Join(s.baseDir, k)
}
return filepath.Join(s.baseDir, k[0:2], k[2:4], k)
}
+156
View File
@@ -0,0 +1,156 @@
package imgcache
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
func TestContentStorage_StoreAndLoadWithSize(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
content := []byte("hello world")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
size, err := storage.Store(key, content)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
if size != int64(len(content)) {
t.Errorf("Store() size = %d, want %d", size, len(content))
}
// Verify file exists at expected path
keyStr := string(key)
expectedPath := filepath.Join(tmpDir, keyStr[0:2], keyStr[2:4], keyStr)
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err)
}
// Load and verify content
r, _, err := storage.LoadWithSize(key)
if err != nil {
t.Fatalf("LoadWithSize() error = %v", err)
}
defer func() { _ = r.Close() }()
loaded, err := io.ReadAll(r)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if !bytes.Equal(loaded, content) {
t.Errorf("LoadWithSize() content = %q, want %q", loaded, content)
}
}
func TestContentStorage_LoadWithSizeNotFound(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
_, _, err = storage.LoadWithSize(imagerequest.SourceKey("nonexistent"))
if !errors.Is(err, ErrNotFound) {
t.Errorf("LoadWithSize() error = %v, want ErrNotFound", err)
}
}
func TestContentStorage_Delete(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
content := []byte("to be deleted")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
_, err = storage.Store(key, content)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
if !storage.Exists(key) {
t.Error("Exists() = false, want true")
}
err = storage.Delete(key)
if err != nil {
t.Fatalf("Delete() error = %v", err)
}
if storage.Exists(key) {
t.Error("Exists() = true after delete, want false")
}
}
func TestContentStorage_DeleteNonexistent(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
// Should not error
err = storage.Delete(imagerequest.SourceKey("nonexistent"))
if err != nil {
t.Errorf("Delete() error = %v, want nil", err)
}
}
func TestContentStorage_KeyToPath(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
// Test by storing and verifying the resulting path structure
content := []byte("test content for path verification")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
_, err = storage.Store(key, content)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
keyStr := string(key)
expectedPath := filepath.Join(tmpDir, keyStr[0:2], keyStr[2:4], keyStr)
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

+16 -11
View File
@@ -1,17 +1,12 @@
package magic package magic
import ( import "testing"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// testMIMEJXL is the MIME type of JPEG XL. // testMIMEJXL is the MIME type of JPEG XL.
const testMIMEJXL = "image/jxl" const testMIMEJXL = "image/jxl"
// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a // TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a
// bare codestream and that of the container, are detected as JPEG XL and // bare codestream and that of the container, are detected as image/jxl.
// match the MIME type image/jxl.
func TestDetectFormatJPEGXL(t *testing.T) { func TestDetectFormatJPEGXL(t *testing.T) {
t.Parallel() t.Parallel()
@@ -30,15 +25,25 @@ func TestDetectFormatJPEGXL(t *testing.T) {
t.Parallel() t.Parallel()
got, err := DetectFormat(tt.data) got, err := DetectFormat(tt.data)
if err != nil || got != imagerequest.FormatJXL { if err != nil || got != MIMETypeJXL {
t.Errorf("DetectFormat() = %q, %v, want %q", t.Errorf("DetectFormat() = %q, %v, want %q", got, err, testMIMEJXL)
got, err, imagerequest.FormatJXL)
} }
_, err = ValidateMagicBytes(tt.data, testMIMEJXL) err = ValidateMagicBytes(tt.data, testMIMEJXL)
if err != nil { if err != nil {
t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err) t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err)
} }
}) })
} }
} }
// TestJPEGXLMIMEType verifies that image/jxl maps to the format jxl.
func TestJPEGXLMIMEType(t *testing.T) {
t.Parallel()
format, ok := MIMEToImageFormat(testMIMEJXL)
if format != FormatJXL || !ok {
t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true",
testMIMEJXL, format, ok, FormatJXL)
}
}
+98 -21
View File
@@ -6,8 +6,6 @@ import (
"bytes" "bytes"
"errors" "errors"
"strings" "strings"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// Magic byte errors. // Magic byte errors.
@@ -17,6 +15,34 @@ var (
ErrNotEnoughData = errors.New("not enough data to detect format") ErrNotEnoughData = errors.New("not enough data to detect format")
) )
// MIMEType represents a supported MIME type for input images.
type MIMEType string
// Supported input MIME types.
const (
MIMETypeJPEG = MIMEType("image/jpeg")
MIMETypePNG = MIMEType("image/png")
MIMETypeWebP = MIMEType("image/webp")
MIMETypeGIF = MIMEType("image/gif")
MIMETypeAVIF = MIMEType("image/avif")
MIMETypeJXL = MIMEType("image/jxl")
MIMETypeSVG = MIMEType("image/svg+xml")
)
// ImageFormat represents supported output image formats.
// This mirrors the type in imgcache to avoid circular imports.
type ImageFormat string
// Supported image output formats.
const (
FormatJPEG ImageFormat = "jpeg"
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif"
)
// MinMagicBytes is the minimum number of bytes needed to detect format. // MinMagicBytes is the minimum number of bytes needed to detect format.
const MinMagicBytes = 12 const MinMagicBytes = 12
@@ -52,32 +78,32 @@ var (
avisBrand = []byte{0x61, 0x76, 0x69, 0x73} // "avis" (AVIF sequence) avisBrand = []byte{0x61, 0x76, 0x69, 0x73} // "avis" (AVIF sequence)
) )
// DetectFormat detects the image format from magic bytes. Data in none of // DetectFormat detects the image format from magic bytes.
// the formats pixa reads, SVG included, is ErrUnknownFormat. // Returns the MIME type and nil error on success.
func DetectFormat(data []byte) (imagerequest.Format, error) { func DetectFormat(data []byte) (MIMEType, error) {
if len(data) < MinMagicBytes { if len(data) < MinMagicBytes {
return "", ErrNotEnoughData return "", ErrNotEnoughData
} }
// Check JPEG (FFD8FF) // Check JPEG (FFD8FF)
if bytes.HasPrefix(data, magicJPEG) { if bytes.HasPrefix(data, magicJPEG) {
return imagerequest.FormatJPEG, nil return MIMETypeJPEG, nil
} }
// Check PNG (89504E47 0D0A1A0A) // Check PNG (89504E47 0D0A1A0A)
if bytes.HasPrefix(data, magicPNG) { if bytes.HasPrefix(data, magicPNG) {
return imagerequest.FormatPNG, nil return MIMETypePNG, nil
} }
// Check GIF (GIF87a or GIF89a) // Check GIF (GIF87a or GIF89a)
if bytes.HasPrefix(data, magicGIF) { if bytes.HasPrefix(data, magicGIF) {
return imagerequest.FormatGIF, nil return MIMETypeGIF, nil
} }
// Check WebP (RIFF....WEBP) // Check WebP (RIFF....WEBP)
if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 { if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 {
if bytes.Equal(data[8:12], webpIdent) { if bytes.Equal(data[8:12], webpIdent) {
return imagerequest.FormatWebP, nil return MIMETypeWebP, nil
} }
} }
@@ -86,39 +112,66 @@ func DetectFormat(data []byte) (imagerequest.Format, error) {
if len(data) >= 12 && bytes.Equal(data[4:8], magicFtyp) { if len(data) >= 12 && bytes.Equal(data[4:8], magicFtyp) {
brand := data[8:12] brand := data[8:12]
if bytes.Equal(brand, avifBrand) || bytes.Equal(brand, avisBrand) { if bytes.Equal(brand, avifBrand) || bytes.Equal(brand, avisBrand) {
return imagerequest.FormatAVIF, nil return MIMETypeAVIF, nil
} }
} }
// Check JPEG XL (FF0A, or the 12-byte container signature) // Check JPEG XL (FF0A, or the 12-byte container signature)
if bytes.HasPrefix(data, magicJXLCodestream) || if bytes.HasPrefix(data, magicJXLCodestream) ||
bytes.HasPrefix(data, magicJXLContainer) { bytes.HasPrefix(data, magicJXLContainer) {
return imagerequest.FormatJXL, nil return MIMETypeJXL, nil
}
// Check SVG - look for XML declaration or SVG tag
if detectSVG(data) {
return MIMETypeSVG, nil
} }
return "", ErrUnknownFormat return "", ErrUnknownFormat
} }
// ValidateMagicBytes detects the format of data from its magic bytes, and // detectSVG checks if data appears to be SVG content.
// returns it when it is the format the declared MIME type names. Data in func detectSVG(data []byte) bool {
// another format is ErrMagicByteMismatch. // Skip BOM if present
func ValidateMagicBytes( content := skipBOM(data)
data []byte, declaredType string,
) (imagerequest.Format, error) { // Convert to string for easier pattern matching
s := strings.ToLower(string(content))
// Skip leading whitespace
s = strings.TrimSpace(s)
// Check for XML declaration or SVG element
return strings.HasPrefix(s, "<?xml") ||
strings.HasPrefix(s, "<svg") ||
strings.HasPrefix(s, "<!doctype svg")
}
// skipBOM removes UTF-8 BOM if present.
func skipBOM(data []byte) []byte {
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
return data[3:]
}
return data
}
// ValidateMagicBytes validates that the content matches the declared MIME type.
func ValidateMagicBytes(data []byte, declaredType string) error {
detected, err := DetectFormat(data) detected, err := DetectFormat(data)
if err != nil { if err != nil {
return "", err return err
} }
// Normalize the declared type (remove parameters like charset) // Normalize the declared type (remove parameters like charset)
normalizedDeclared := normalizeMIMEType(declaredType) normalizedDeclared := normalizeMIMEType(declaredType)
// Check if they match // Check if they match
if detected.MIMEType() != normalizedDeclared { if string(detected) != normalizedDeclared {
return "", ErrMagicByteMismatch return ErrMagicByteMismatch
} }
return detected, nil return nil
} }
// normalizeMIMEType extracts just the media type, removing parameters. // normalizeMIMEType extracts just the media type, removing parameters.
@@ -130,3 +183,27 @@ func normalizeMIMEType(mimeType string) string {
return strings.TrimSpace(strings.ToLower(mimeType)) return strings.TrimSpace(strings.ToLower(mimeType))
} }
// MIMEToImageFormat converts a MIME type to an ImageFormat.
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) {
case MIMETypeJPEG:
return FormatJPEG, true
case MIMETypePNG:
return FormatPNG, true
case MIMETypeWebP:
return FormatWebP, true
case MIMETypeGIF:
return FormatGIF, true
case MIMETypeAVIF:
return FormatAVIF, true
case MIMETypeJXL:
return FormatJXL, true
case MIMETypeSVG:
// SVG has no corresponding output format.
return "", false
default:
return "", false
}
}
+171 -24
View File
@@ -1,10 +1,9 @@
package magic package magic
import ( import (
"bytes"
"errors" "errors"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// Shared test fixture strings. // Shared test fixture strings.
@@ -13,6 +12,9 @@ const (
testMIMEJPEG = "image/jpeg" testMIMEJPEG = "image/jpeg"
testMIMEJPEGParams = "image/jpeg; charset=utf-8" testMIMEJPEGParams = "image/jpeg; charset=utf-8"
testMIMEPNG = "image/png" testMIMEPNG = "image/png"
testMIMEWebP = "image/webp"
testMIMEGIF = "image/gif"
testMIMEAVIF = "image/avif"
) )
// pad appends zero bytes so data is comfortably above MinMagicBytes. // pad appends zero bytes so data is comfortably above MinMagicBytes.
@@ -34,22 +36,37 @@ func TestDetectFormat(t *testing.T) {
avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73) avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73)
tests := []struct { tests := []struct {
name string name string
data []byte data []byte
wantFormat imagerequest.Format wantMIME MIMEType
wantErr error wantErr error
}{ }{
{name: "JPEG", data: jpeg, wantFormat: imagerequest.FormatJPEG}, {name: "JPEG", data: jpeg, wantMIME: MIMETypeJPEG},
{name: "PNG", data: png, wantFormat: imagerequest.FormatPNG}, {name: "PNG", data: png, wantMIME: MIMETypePNG},
{name: "GIF87a", data: gif87a, wantFormat: imagerequest.FormatGIF}, {name: "GIF87a", data: gif87a, wantMIME: MIMETypeGIF},
{name: "GIF89a", data: gif89a, wantFormat: imagerequest.FormatGIF}, {name: "GIF89a", data: gif89a, wantMIME: MIMETypeGIF},
{name: "WebP", data: webp, wantFormat: imagerequest.FormatWebP}, {name: "WebP", data: webp, wantMIME: MIMETypeWebP},
{name: "AVIF", data: avif, wantFormat: imagerequest.FormatAVIF}, {name: "AVIF", data: avif, wantMIME: MIMETypeAVIF},
{name: "AVIF sequence", data: avis, wantFormat: imagerequest.FormatAVIF}, {name: "AVIF sequence", data: avis, wantMIME: MIMETypeAVIF},
{ {
name: "SVG", name: "SVG with XML declaration",
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), data: []byte(`<?xml version="1.0"?><svg></svg>`),
wantErr: ErrUnknownFormat, wantMIME: MIMETypeSVG,
},
{
name: "SVG without declaration",
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
wantMIME: MIMETypeSVG,
},
{
name: "SVG with whitespace",
data: []byte(` <?xml version="1.0"?><svg></svg>`),
wantMIME: MIMETypeSVG,
},
{
name: "SVG with BOM",
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
wantMIME: MIMETypeSVG,
}, },
{ {
name: "unknown format", name: "unknown format",
@@ -71,8 +88,8 @@ func TestDetectFormat(t *testing.T) {
return return
} }
if got != tt.wantFormat { if got != tt.wantMIME {
t.Errorf("DetectFormat() = %v, want %v", got, tt.wantFormat) t.Errorf("DetectFormat() = %v, want %v", got, tt.wantMIME)
} }
}) })
} }
@@ -88,28 +105,24 @@ func TestValidateMagicBytes(t *testing.T) {
name string name string
data []byte data []byte
declaredType string declaredType string
wantFormat imagerequest.Format
wantErr error wantErr error
}{ }{
{ {
name: "matching JPEG", name: "matching JPEG",
data: jpegData, data: jpegData,
declaredType: testMIMEJPEG, declaredType: testMIMEJPEG,
wantFormat: imagerequest.FormatJPEG,
wantErr: nil, wantErr: nil,
}, },
{ {
name: "matching JPEG with params", name: "matching JPEG with params",
data: jpegData, data: jpegData,
declaredType: testMIMEJPEGParams, declaredType: testMIMEJPEGParams,
wantFormat: imagerequest.FormatJPEG,
wantErr: nil, wantErr: nil,
}, },
{ {
name: "matching PNG", name: "matching PNG",
data: pngData, data: pngData,
declaredType: testMIMEPNG, declaredType: testMIMEPNG,
wantFormat: imagerequest.FormatPNG,
wantErr: nil, wantErr: nil,
}, },
{ {
@@ -130,14 +143,45 @@ func TestValidateMagicBytes(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
got, err := ValidateMagicBytes(tt.data, tt.declaredType) err := ValidateMagicBytes(tt.data, tt.declaredType)
if !errors.Is(err, tt.wantErr) { if !errors.Is(err, tt.wantErr) {
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
} }
})
}
}
func TestMIMEToImageFormat(t *testing.T) {
t.Parallel()
tests := []struct {
mimeType string
wantFormat ImageFormat
wantOk bool
}{
{testMIMEJPEG, FormatJPEG, true},
{testMIMEPNG, FormatPNG, true},
{testMIMEWebP, FormatWebP, true},
{testMIMEGIF, FormatGIF, true},
{testMIMEAVIF, FormatAVIF, true},
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
{"image/tiff", "", false},
{"text/plain", "", false},
}
for _, tt := range tests {
t.Run(tt.mimeType, func(t *testing.T) {
t.Parallel()
got, ok := MIMEToImageFormat(tt.mimeType)
if ok != tt.wantOk {
t.Errorf("MIMEToImageFormat(%q) ok = %v, want %v", tt.mimeType, ok, tt.wantOk)
}
if got != tt.wantFormat { if got != tt.wantFormat {
t.Errorf("ValidateMagicBytes() = %q, want %q", got, tt.wantFormat) t.Errorf("MIMEToImageFormat(%q) = %v, want %v", tt.mimeType, got, tt.wantFormat)
} }
}) })
} }
@@ -170,6 +214,109 @@ func TestNormalizeMIMEType(t *testing.T) {
} }
} }
func TestDetectSVG(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data string
want bool
}{
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
{
"doctype",
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`,
true,
},
{"with whitespace", `
<?xml version="1.0"?><svg></svg>`, true},
{"uppercase", `<SVG></SVG>`, true},
{"not svg", `<html></html>`, false},
{"random text", `hello world`, false},
{testNameEmpty, ``, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := detectSVG([]byte(tt.data))
if got != tt.want {
t.Errorf("detectSVG(%q) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
func TestSkipBOM(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data []byte
want []byte
}{
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
{"without BOM", []byte("hello"), []byte("hello")},
{testNameEmpty, []byte{}, []byte{}},
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := skipBOM(tt.data)
if !bytes.Equal(got, tt.want) {
t.Errorf("skipBOM() = %v, want %v", got, tt.want)
}
})
}
}
func TestRealWorldSVGPatterns(t *testing.T) {
t.Parallel()
// Test various real-world SVG patterns
svgPatterns := []string{
`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40"/>
</svg>`,
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
</svg>`,
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` + `
<svg xmlns="http://www.w3.org/2000/svg">
</svg>`,
}
for i, pattern := range svgPatterns {
data := []byte(pattern)
if len(data) < MinMagicBytes {
// Pad short SVGs for detection
data = append(data, make([]byte, MinMagicBytes-len(data))...)
}
got, err := DetectFormat(data)
if err != nil {
t.Errorf("Pattern %d: DetectFormat() error = %v", i, err)
continue
}
if got != MIMETypeSVG {
t.Errorf("Pattern %d: DetectFormat() = %v, want %v", i, got, MIMETypeSVG)
}
}
}
func TestDetectFormatRIFFNotWebP(t *testing.T) { func TestDetectFormatRIFFNotWebP(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -227,7 +227,7 @@ type imageServiceParams struct {
func newImageService( func newImageService(
_ fx.Lifecycle, params imageServiceParams, _ fx.Lifecycle, params imageServiceParams,
) (*imageproxy.Proxy, error) { ) (*imageproxy.Proxy, error) {
return imageproxy.New(&imageproxy.Config{ return imageproxy.NewProxy(&imageproxy.Config{
Cache: params.Cache, Cache: params.Cache,
Fetcher: params.Fetcher, Fetcher: params.Fetcher,
Processor: params.Processor, Processor: params.Processor,