Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4df5077b |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,97 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// writeTestConfig writes yamlContent to a temp config file and returns
|
||||||
|
// the file path.
|
||||||
|
func writeTestConfig(t *testing.T, yamlContent string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAllowlistHosts loads the config at configPath and asserts that
|
||||||
|
// getStringSlice returns the three expected hosts.
|
||||||
|
func checkAllowlistHosts(t *testing.T, configPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
|
||||||
|
if len(hosts) != 3 {
|
||||||
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
|
||||||
|
for i, want := range expected {
|
||||||
|
if i >= len(hosts) {
|
||||||
|
t.Errorf("missing host at index %d: want %q", i, want)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hosts[i] != want {
|
||||||
|
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := `
|
||||||
|
allowlist_hosts:
|
||||||
|
- static.sneak.cloud
|
||||||
|
- sneak.berlin
|
||||||
|
- s3.sneak.cloud
|
||||||
|
`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Backwards compatibility with comma-separated string values.
|
||||||
|
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := writeTestConfig(t, `port: 8080`)
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
if len(hosts) != 0 {
|
||||||
|
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
||||||
// with a real signing key, as Getting Started in README.md has it copied
|
// with a real signing key, as Getting Started in README.md has it copied
|
||||||
// and edited, and checks that it allowlists no host: every image on an
|
// and edited, and checks that it allowlists no host: every image on an
|
||||||
|
|||||||
@@ -15,36 +15,8 @@ import (
|
|||||||
_ "modernc.org/sqlite" // SQLite driver registration
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
)
|
)
|
||||||
|
|
||||||
// The config keys the tests name in the errors they expect.
|
|
||||||
const (
|
|
||||||
keyDebug = "debug"
|
|
||||||
keyMaintenanceMode = "maintenance_mode"
|
|
||||||
keyPort = "port"
|
|
||||||
keyStateDir = "state_dir"
|
|
||||||
keySentryDSN = "sentry_dsn"
|
|
||||||
keyDBURL = "db_url"
|
|
||||||
keyMetrics = "metrics"
|
|
||||||
keyMetricsUsername = "metrics.username"
|
|
||||||
keyMetricsPassword = "metrics.password"
|
|
||||||
keySigningKey = "signing_key"
|
|
||||||
keyAllowlistHosts = "allowlist_hosts"
|
|
||||||
keyRefererBlocklist = "referer_blocklist"
|
|
||||||
keyAllowHTTP = "allow_http"
|
|
||||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
|
||||||
keyUpstreamConnections = "upstream_connections"
|
|
||||||
keyMaxConcurrentProcessing = "max_concurrent_processing"
|
|
||||||
keyCacheMaxBytes = "cache_max_bytes"
|
|
||||||
keyBlockedNetworks = "blocked_networks"
|
|
||||||
keyTrustedProxies = "trusted_proxies"
|
|
||||||
keyAccessControlAllowOrigin = "access_control_allow_origin"
|
|
||||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
|
||||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
|
||||||
keyDownstreamTimeout = "downstream_timeout"
|
|
||||||
keyImageRequestsPerMinute = "image_requests_per_minute"
|
|
||||||
)
|
|
||||||
|
|
||||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
// minimum length requirement in validateSigningKey.
|
// minimum length requirement in validate().
|
||||||
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
// signingKeyLine is a valid signing_key config line used as the base of
|
// signingKeyLine is a valid signing_key config line used as the base of
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestReadmeNamesEverySetting checks that the list of settings in
|
|
||||||
// README.md names exactly the keys of the settings table, one item each.
|
|
||||||
// The list is the first one in the Configuration section, and it ends at
|
|
||||||
// the first blank line. Each item starts with its key in backticks.
|
|
||||||
func TestReadmeNamesEverySetting(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
readme, err := os.ReadFile(filepath.Join("..", "..", "README.md"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read README.md: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, section, found := strings.Cut(string(readme), "\n### Configuration\n")
|
|
||||||
if !found {
|
|
||||||
t.Fatal("README.md has no Configuration section")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The section ends at the next heading of its level or above.
|
|
||||||
section, _, _ = strings.Cut(section, "\n## ")
|
|
||||||
section, _, _ = strings.Cut(section, "\n### ")
|
|
||||||
|
|
||||||
_, list, found := strings.Cut(section, "\n- ")
|
|
||||||
if !found {
|
|
||||||
t.Fatal("README.md's Configuration section has no list")
|
|
||||||
}
|
|
||||||
|
|
||||||
list, _, _ = strings.Cut("- "+list, "\n\n")
|
|
||||||
|
|
||||||
itemKey := regexp.MustCompile("^- `([^`]+)`")
|
|
||||||
named := map[string]bool{}
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(list, "\n") {
|
|
||||||
// A line that does not start with "- " goes on with the item above.
|
|
||||||
if !strings.HasPrefix(line, "- ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
match := itemKey.FindStringSubmatch(line)
|
|
||||||
if match == nil {
|
|
||||||
t.Errorf("README.md's list of settings has an item "+
|
|
||||||
"that does not start with a key in backticks: %s", line)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if named[match[1]] {
|
|
||||||
t.Errorf("README.md's list of settings names %s twice", match[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
named[match[1]] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range new(Config).settings() {
|
|
||||||
if !named[s.key] {
|
|
||||||
t.Errorf("README.md's list of settings does not name %s", s.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(named, s.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key := range named {
|
|
||||||
t.Errorf("README.md's list of settings names %s, "+
|
|
||||||
"which is not a setting", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExampleConfigNamesEverySetting checks that
|
|
||||||
// configs/config.example.yml names exactly the keys of the settings
|
|
||||||
// table, each one set or commented out with "# ". A key line holds the
|
|
||||||
// key, a colon and at most a one-word value, which tells it from a
|
|
||||||
// comment line that starts with a word and a colon. An indented key is
|
|
||||||
// inside the map of the key above it, as username is inside metrics.
|
|
||||||
func TestExampleConfigNamesEverySetting(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const examplePath = "configs/config.example.yml"
|
|
||||||
|
|
||||||
example, err := os.ReadFile(filepath.Join("..", "..", examplePath))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read %s: %v", examplePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keyLine := regexp.MustCompile(`(?m)^(?:# )?( *)([a-z_]+):(?: \S+)?$`)
|
|
||||||
named := map[string]bool{}
|
|
||||||
|
|
||||||
var mapKey string
|
|
||||||
|
|
||||||
for _, match := range keyLine.FindAllStringSubmatch(string(example), -1) {
|
|
||||||
indent, key := match[1], match[2]
|
|
||||||
if indent == "" {
|
|
||||||
mapKey = key
|
|
||||||
named[key] = true
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The key holding the map is not itself a setting.
|
|
||||||
delete(named, mapKey)
|
|
||||||
|
|
||||||
named[mapKey+"."+key] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// The env section sets environment variables; it is not a setting.
|
|
||||||
delete(named, "env")
|
|
||||||
|
|
||||||
for _, s := range new(Config).settings() {
|
|
||||||
if !named[s.key] {
|
|
||||||
t.Errorf("%s does not name %s", examplePath, s.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(named, s.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key := range named {
|
|
||||||
t.Errorf("%s names %s, which is not a setting", examplePath, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -138,8 +138,7 @@ func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
|||||||
|
|
||||||
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
||||||
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
||||||
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the
|
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
||||||
// settings table.
|
|
||||||
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||||
// A config file's env section loaded by another test can leave a
|
// A config file's env section loaded by another test can leave a
|
||||||
// PIXA_ variable set for the whole process, so every one is unset
|
// PIXA_ variable set for the whole process, so every one is unset
|
||||||
@@ -160,8 +159,8 @@ func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
|||||||
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
||||||
|
|
||||||
for _, s := range new(Config).settings() {
|
for _, name := range envVarNames() {
|
||||||
t.Setenv(s.envVar, "")
|
t.Setenv(name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := validateKnownEnvVars()
|
err := validateKnownEnvVars()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 281 B |
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user