2 Commits
Author SHA1 Message Date
sneak fece3b69c9 One image request type instead of five (closes #307)
check / check (push) Canceled after 0s
The new package internal/imagerequest holds the request type, its two
parts Source and Transform, the Format and Fit types, the dimension and
fit checks, the /v1/image/ path parsing and the variant key; it imports
no other package of pixa. imgcache.ParsedURL, imgcache.ImageRequest,
signature.Request, imageprocessor.Request, the second Size, format and
fit types and the conversions between them are gone. The image
processor takes a Transform only. encurl keeps a private struct with the
token's CBOR field names, so tokens made before still decode. VariantKey
moves with the function that makes it. Tests follow the moved and
renamed code; the tests of the removed conversions go with them.

Model: opus-5-5
2026-10-21 12:27:29 +00:00
clawbot 385c0ca76f Run only script/cibuild in the Gitea workflow (closes #203)
check / check (push) Canceled after 0s
The workflow's script/docker-smoke step is removed, so the workflow
checks out the repo and runs script/cibuild and nothing else, as the
canonical REPO_POLICIES.md says. The owner ruled on the issue that
make check covers what needs covering.

CI no longer starts the image and waits for its healthcheck.
script/docker-smoke and make docker-smoke stay, to run by hand, and
the script's header comment no longer says the workflow runs it. The
dated TODO.md entries that say the workflow runs it are history and
stay as written.

Model: opus-5-5
2026-10-21 13:22:59 +02:00
70 changed files with 2225 additions and 1982 deletions
-1
View File
@@ -18,4 +18,3 @@ jobs:
# All history and tags, so git describe finds the version tag. # All history and tags, so git describe finds the version tag.
fetch-depth: 0 fetch-depth: 0
- run: script/cibuild - run: script/cibuild
- run: script/docker-smoke
+16
View File
@@ -30,6 +30,22 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-21 one image request type instead of five (closes #307): the new
package `internal/imagerequest`, which imports no other package of pixa, holds
`imagerequest.Request` with its two parts, `Source` (host, path and query) and
`Transform` (size, format, quality and fit), the `Format` and `Fit` types, the
checks, the parsing of the `/v1/image/` path and the variant key.
`imgcache.ParsedURL`, `imgcache.ImageRequest`, `signature.Request`,
`imageprocessor.Request`, the second `Size`, format and fit types and the
conversions between them are gone. The image processor takes a `Transform`
only, and `encurl` keeps a private struct with the token's short CBOR field
names, so tokens made before still decode. Every URL of both image routes
answers as before.
- 2026-10-21 the Gitea workflow checks out the repo and runs `script/cibuild`,
nothing else (closes #203): its `script/docker-smoke` step is removed, as the
canonical `REPO_POLICIES.md` says, since `make check` covers what CI needs to
check. CI no longer starts the image and waits for its healthcheck.
`script/docker-smoke` and `make docker-smoke` stay, to run by hand.
- 2026-10-20 a WebP, AVIF or JPEG XL encode at the limits takes at most about 34 - 2026-10-20 a WebP, AVIF or JPEG XL encode at the limits takes at most about 34
seconds (closes #272): with one libvips thread, 8192x8192 random pixels with seconds (closes #272): with one libvips thread, 8192x8192 random pixels with
an alpha channel took about 61 seconds to encode as WebP, 81 as JPEG XL and, an alpha channel took about 61 seconds to encode as WebP, 81 as JPEG XL and,
+52 -65
View File
@@ -7,15 +7,15 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/seal" "sneak.berlin/go/pixa/internal/seal"
) )
// Default values for optional fields. // Default values for optional fields.
const ( const (
DefaultQuality = 85 DefaultQuality = 85
DefaultFormat = imgcache.FormatJXL DefaultFormat = imagerequest.FormatJXL
DefaultFitMode = imgcache.FitCover DefaultFitMode = imagerequest.FitCover
// HKDF salt for URL encryption key derivation // HKDF salt for URL encryption key derivation
urlKeySalt = "pixa-urlenc-v1" urlKeySalt = "pixa-urlenc-v1"
@@ -28,19 +28,19 @@ var (
ErrDecryptFailed = errors.New("failed to decrypt URL") ErrDecryptFailed = errors.New("failed to decrypt URL")
) )
// Payload contains all image request parameters in a compact format. // payload contains all image request parameters in a compact format.
// Field names are short to minimize encoded size. // Field names are short to minimize encoded size.
// Fields with omitempty use sane defaults when absent. // Fields with omitempty use sane defaults when absent.
type Payload struct { type payload struct {
SourceHost string `cbor:"h"` // required SourceHost string `cbor:"h"` // required
SourcePath string `cbor:"p"` // required SourcePath string `cbor:"p"` // required
SourceQuery string `cbor:"q,omitempty"` // optional SourceQuery string `cbor:"q,omitempty"` // optional
Width int `cbor:"w,omitempty"` // 0 = original Width int `cbor:"w,omitempty"` // 0 = original
Height int `cbor:"ht,omitempty"` // 0 = original Height int `cbor:"ht,omitempty"` // 0 = original
Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl Format imagerequest.Format `cbor:"f,omitempty"` // default: jxl
Quality int `cbor:"ql,omitempty"` // default: 85 Quality int `cbor:"ql,omitempty"` // default: 85
FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover FitMode imagerequest.Fit `cbor:"fm,omitempty"` // default: cover
ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires
} }
// Generator creates and parses encrypted URL tokens. // Generator creates and parses encrypted URL tokens.
@@ -59,9 +59,27 @@ func NewGenerator(signingKey string) (*Generator, error) {
return &Generator{key: key}, nil return &Generator{key: key}, nil
} }
// Generate creates an encrypted URL token from the payload. // Generate creates an encrypted URL token from the request's source,
// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded. // transform and expiry, each as the request holds it; an empty format, a
func (g *Generator) Generate(p *Payload) (string, error) { // quality of 0 or an empty fit mode is left out of the token, and a zero
// Expires makes a token that never expires. The token is CBOR-encoded,
// encrypted with NaCl secretbox, and base64url-encoded.
func (g *Generator) Generate(req *imagerequest.Request) (string, error) {
p := &payload{
SourceHost: req.Source.Host,
SourcePath: req.Source.Path,
SourceQuery: req.Source.Query,
Width: req.Transform.Size.Width,
Height: req.Transform.Size.Height,
Format: req.Transform.Format,
Quality: req.Transform.Quality,
FitMode: req.Transform.FitMode,
}
if !req.Expires.IsZero() {
p.ExpiresAt = req.Expires.Unix()
}
// CBOR encode the payload // CBOR encode the payload
data, err := cbor.Marshal(p) data, err := cbor.Marshal(p)
if err != nil { if err != nil {
@@ -72,9 +90,11 @@ func (g *Generator) Generate(p *Payload) (string, error) {
return seal.Encrypt(g.key, data) return seal.Encrypt(g.key, data)
} }
// Parse decrypts and validates an encrypted URL token. // Parse decrypts and validates an encrypted URL token and returns the
// request it holds, with default values for omitted optional fields. A token
// that never expires gives the zero Expires.
// Returns ErrExpired if the token has expired, or other errors for invalid tokens. // Returns ErrExpired if the token has expired, or other errors for invalid tokens.
func (g *Generator) Parse(token string) (*Payload, error) { func (g *Generator) Parse(token string) (*imagerequest.Request, error) {
// Decrypt // Decrypt
data, err := seal.Decrypt(g.key, token) data, err := seal.Decrypt(g.key, token)
if err != nil { if err != nil {
@@ -87,7 +107,7 @@ func (g *Generator) Parse(token string) (*Payload, error) {
} }
// CBOR decode // CBOR decode
var p Payload var p payload
err = cbor.Unmarshal(data, &p) err = cbor.Unmarshal(data, &p)
if err != nil { if err != nil {
@@ -99,13 +119,6 @@ func (g *Generator) Parse(token string) (*Payload, error) {
return nil, ErrExpired return nil, ErrExpired
} }
return &p, nil
}
// ToImageRequest converts the payload to an ImageRequest.
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
// that never expires, gives the zero Expires.
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
format := p.Format format := p.Format
if format == "" { if format == "" {
format = DefaultFormat format = DefaultFormat
@@ -126,44 +139,18 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
expires = time.Unix(p.ExpiresAt, 0) expires = time.Unix(p.ExpiresAt, 0)
} }
return &imgcache.ImageRequest{ return &imagerequest.Request{
SourceHost: p.SourceHost, Source: imagerequest.Source{
SourcePath: p.SourcePath, Host: p.SourceHost,
SourceQuery: p.SourceQuery, Path: p.SourcePath,
Size: imgcache.Size{ Query: p.SourceQuery,
Width: p.Width, },
Height: p.Height, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: p.Width, Height: p.Height},
Format: format,
Quality: quality,
FitMode: fitMode,
}, },
Format: format,
Quality: quality,
FitMode: fitMode,
Expires: expires, Expires: expires,
} }, nil
}
// FromImageRequest creates a Payload from an ImageRequest with the given expiration.
func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload {
p := &Payload{
SourceHost: req.SourceHost,
SourcePath: req.SourcePath,
SourceQuery: req.SourceQuery,
Width: req.Size.Width,
Height: req.Size.Height,
ExpiresAt: expiresAt.Unix(),
}
// Only set non-default values to benefit from omitempty
if req.Format != "" && req.Format != DefaultFormat {
p.Format = req.Format
}
if req.Quality != 0 && req.Quality != DefaultQuality {
p.Quality = req.Quality
}
if req.FitMode != "" && req.FitMode != DefaultFitMode {
p.FitMode = req.FitMode
}
return p
} }
+91 -174
View File
@@ -6,7 +6,7 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// Shared test fixture strings. // Shared test fixture strings.
@@ -24,19 +24,22 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
t.Fatalf("NewGenerator() error = %v", err) t.Fatalf("NewGenerator() error = %v", err)
} }
payload := &encurl.Payload{ req := &imagerequest.Request{
SourceHost: testSourceHost, Source: imagerequest.Source{
SourcePath: testSourcePath, Host: testSourceHost,
SourceQuery: testSourceQuery, Path: testSourcePath,
Width: 800, Query: testSourceQuery,
Height: 600, },
Format: imgcache.FormatWebP, Transform: imagerequest.Transform{
Quality: 90, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: imgcache.FitContain, Format: imagerequest.FormatWebP,
ExpiresAt: time.Now().Add(time.Hour).Unix(), Quality: 90,
FitMode: imagerequest.FitContain,
},
Expires: time.Now().Add(time.Hour),
} }
token, err := gen.Generate(payload) token, err := gen.Generate(req)
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
} }
@@ -51,40 +54,45 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
} }
// Verify all fields // Verify all fields
if parsed.SourceHost != payload.SourceHost { if parsed.Source.Host != req.Source.Host {
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost) t.Errorf("SourceHost = %q, want %q", parsed.Source.Host, req.Source.Host)
} }
if parsed.SourcePath != payload.SourcePath { if parsed.Source.Path != req.Source.Path {
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath) t.Errorf("SourcePath = %q, want %q", parsed.Source.Path, req.Source.Path)
} }
if parsed.SourceQuery != payload.SourceQuery { if parsed.Source.Query != req.Source.Query {
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery) t.Errorf("SourceQuery = %q, want %q", parsed.Source.Query, req.Source.Query)
} }
if parsed.Width != payload.Width { if parsed.Transform.Size.Width != req.Transform.Size.Width {
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width) t.Errorf("Width = %d, want %d",
parsed.Transform.Size.Width, req.Transform.Size.Width)
} }
if parsed.Height != payload.Height { if parsed.Transform.Size.Height != req.Transform.Size.Height {
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height) t.Errorf("Height = %d, want %d",
parsed.Transform.Size.Height, req.Transform.Size.Height)
} }
if parsed.Format != payload.Format { if parsed.Transform.Format != req.Transform.Format {
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format) t.Errorf("Format = %q, want %q",
parsed.Transform.Format, req.Transform.Format)
} }
if parsed.Quality != payload.Quality { if parsed.Transform.Quality != req.Transform.Quality {
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality) t.Errorf("Quality = %d, want %d",
parsed.Transform.Quality, req.Transform.Quality)
} }
if parsed.FitMode != payload.FitMode { if parsed.Transform.FitMode != req.Transform.FitMode {
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode) t.Errorf("FitMode = %q, want %q",
parsed.Transform.FitMode, req.Transform.FitMode)
} }
if parsed.ExpiresAt != payload.ExpiresAt { if parsed.Expires.Unix() != req.Expires.Unix() {
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt) t.Errorf("ExpiresAt = %d, want %d", parsed.Expires.Unix(), req.Expires.Unix())
} }
} }
@@ -93,13 +101,15 @@ func TestGenerator_Parse_Expired(t *testing.T) {
gen, _ := encurl.NewGenerator("test-signing-key-12345") gen, _ := encurl.NewGenerator("test-signing-key-12345")
payload := &encurl.Payload{ req := &imagerequest.Request{
SourceHost: testSourceHost, Source: imagerequest.Source{
SourcePath: testSourcePath, Host: testSourceHost,
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired Path: testSourcePath,
},
Expires: time.Now().Add(-time.Hour), // Already expired
} }
token, err := gen.Generate(payload) token, err := gen.Generate(req)
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
} }
@@ -130,13 +140,15 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
gen, _ := encurl.NewGenerator("test-signing-key-12345") gen, _ := encurl.NewGenerator("test-signing-key-12345")
payload := &encurl.Payload{ req := &imagerequest.Request{
SourceHost: testSourceHost, Source: imagerequest.Source{
SourcePath: testSourcePath, Host: testSourceHost,
ExpiresAt: time.Now().Add(time.Hour).Unix(), Path: testSourcePath,
},
Expires: time.Now().Add(time.Hour),
} }
token, _ := gen.Generate(payload) token, _ := gen.Generate(req)
// Tamper with the token // Tamper with the token
tampered := []byte(token) tampered := []byte(token)
@@ -156,13 +168,15 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
gen1, _ := encurl.NewGenerator("signing-key-1") gen1, _ := encurl.NewGenerator("signing-key-1")
gen2, _ := encurl.NewGenerator("signing-key-2") gen2, _ := encurl.NewGenerator("signing-key-2")
payload := &encurl.Payload{ req := &imagerequest.Request{
SourceHost: testSourceHost, Source: imagerequest.Source{
SourcePath: testSourcePath, Host: testSourceHost,
ExpiresAt: time.Now().Add(time.Hour).Unix(), Path: testSourcePath,
},
Expires: time.Now().Add(time.Hour),
} }
token, _ := gen1.Generate(payload) token, _ := gen1.Generate(req)
_, err := gen2.Parse(token) _, err := gen2.Parse(token)
if err == nil { if err == nil {
@@ -170,140 +184,41 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
} }
} }
func TestPayload_ToImageRequest(t *testing.T) { func TestGenerator_Parse_Defaults(t *testing.T) {
t.Parallel() t.Parallel()
payload := &encurl.Payload{ gen, _ := encurl.NewGenerator("test-signing-key-12345")
SourceHost: testSourceHost,
SourcePath: testSourcePath, // Request with only required fields - should get defaults
SourceQuery: testSourceQuery, token, err := gen.Generate(&imagerequest.Request{
Width: 800, Source: imagerequest.Source{
Height: 600, Host: testSourceHost,
Format: imgcache.FormatWebP, Path: testSourcePath,
Quality: 90, },
FitMode: imgcache.FitContain, Expires: time.Now().Add(time.Hour),
ExpiresAt: time.Now().Add(time.Hour).Unix(), })
if err != nil {
t.Fatalf("Generate() error = %v", err)
} }
req := payload.ToImageRequest() req, err := gen.Parse(token)
if err != nil {
if req.SourceHost != payload.SourceHost { t.Fatalf("Parse() error = %v", err)
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
} }
if req.SourcePath != payload.SourcePath { if req.Transform.Format != encurl.DefaultFormat {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath) t.Errorf("Format = %q, want default %q",
req.Transform.Format, encurl.DefaultFormat)
} }
if req.SourceQuery != payload.SourceQuery { if req.Transform.Quality != encurl.DefaultQuality {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery) t.Errorf("Quality = %d, want default %d",
req.Transform.Quality, encurl.DefaultQuality)
} }
if req.Size.Width != payload.Width { if req.Transform.FitMode != encurl.DefaultFitMode {
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width) t.Errorf("FitMode = %q, want default %q",
} req.Transform.FitMode, encurl.DefaultFitMode)
if req.Size.Height != payload.Height {
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
}
if req.Format != payload.Format {
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
}
if req.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
}
if req.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
}
}
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
t.Parallel()
// Payload with only required fields - should get defaults
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
req := payload.ToImageRequest()
if req.Format != encurl.DefaultFormat {
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
}
if req.Quality != encurl.DefaultQuality {
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
}
if req.FitMode != encurl.DefaultFitMode {
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
}
}
func TestFromImageRequest(t *testing.T) {
t.Parallel()
req := &imgcache.ImageRequest{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
SourceQuery: testSourceQuery,
Size: imgcache.Size{Width: 800, Height: 600},
Format: imgcache.FormatWebP,
Quality: 90,
FitMode: imgcache.FitContain,
}
expiresAt := time.Now().Add(time.Hour)
payload := encurl.FromImageRequest(req, expiresAt)
if payload.SourceHost != req.SourceHost {
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
}
if payload.SourcePath != req.SourcePath {
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
}
if payload.Width != req.Size.Width {
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
}
if payload.ExpiresAt != expiresAt.Unix() {
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
}
}
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
t.Parallel()
// Request with default values - payload should omit them for
// smaller encoding
req := &imgcache.ImageRequest{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
Format: encurl.DefaultFormat,
Quality: encurl.DefaultQuality,
FitMode: encurl.DefaultFitMode,
}
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
// These should be zero/empty because they match defaults
if payload.Format != "" {
t.Errorf("Format should be empty for default, got %q", payload.Format)
}
if payload.Quality != 0 {
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
}
if payload.FitMode != "" {
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
} }
} }
@@ -312,13 +227,15 @@ func TestGenerator_TokenIsURLSafe(t *testing.T) {
gen, _ := encurl.NewGenerator("test-signing-key-12345") gen, _ := encurl.NewGenerator("test-signing-key-12345")
payload := &encurl.Payload{ req := &imagerequest.Request{
SourceHost: testSourceHost, Source: imagerequest.Source{
SourcePath: testSourcePath, Host: testSourceHost,
ExpiresAt: time.Now().Add(time.Hour).Unix(), Path: testSourcePath,
},
Expires: time.Now().Add(time.Hour),
} }
token, _ := gen.Generate(payload) token, _ := gen.Generate(req)
// Check that token only contains URL-safe characters // Check that token only contains URL-safe characters
for _, c := range token { for _, c := range token {
+33 -35
View File
@@ -16,7 +16,7 @@ import (
"sneak.berlin/go/pixa/internal/clientip" "sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/templates" "sneak.berlin/go/pixa/internal/templates"
) )
@@ -145,7 +145,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return return
} }
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form) req, expiresAt, ttl, err := buildGenerateRequest(parsed, r.Form)
if err != nil { if err != nil {
s.renderGeneratorWithForm(w, r, err.Error(), r.Form, s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
http.StatusBadRequest) http.StatusBadRequest)
@@ -154,9 +154,8 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
} }
// The image URL must fetch the source URL as it was written // The image URL must fetch the source URL as it was written
sourceRequest := payload.ToImageRequest() req.AllowHTTP = s.config.AllowHTTP
sourceRequest.AllowHTTP = s.config.AllowHTTP fetchURL := req.SourceURL()
fetchURL := sourceRequest.SourceURL()
if !fetchedAsWritten(parsed, fetchURL) { if !fetchedAsWritten(parsed, fetchURL) {
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+ s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
@@ -167,7 +166,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
} }
// Generate encrypted token // Generate encrypted token
token, err := s.encGen.Generate(payload) token, err := s.encGen.Generate(req)
if err != nil { if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err) s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form, s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
@@ -240,14 +239,15 @@ func fetchedAsWritten(source *url.URL, fetchURL string) bool {
fetched.RawQuery == source.RawQuery fetched.RawQuery == source.RawQuery
} }
// buildGeneratePayload parses the numeric form fields and assembles the // buildGenerateRequest parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A // image request the encrypted URL's token is made from. ttl=0 means never
// non-numeric or out-of-range width, height, quality or ttl, or an // expires (Expires stays the zero time). A non-numeric or out-of-range
// unrecognized fit mode, is a client error naming the offending field. The // width, height, quality or ttl, or an unrecognized fit mode, is a client
// format field is passed through unchecked. // error naming the offending field. The format field is passed through
func buildGeneratePayload( // unchecked.
func buildGenerateRequest(
parsed *url.URL, form url.Values, parsed *url.URL, form url.Values,
) (*encurl.Payload, time.Time, int, error) { ) (*imagerequest.Request, time.Time, int, error) {
width, err := parseFormDimension(form, "width") width, err := parseFormDimension(form, "width")
if err != nil { if err != nil {
return nil, time.Time{}, 0, err return nil, time.Time{}, 0, err
@@ -269,42 +269,40 @@ func buildGeneratePayload(
return nil, time.Time{}, 0, err return nil, time.Time{}, 0, err
} }
fitMode := imgcache.FitMode(form.Get("fit")) fitMode := imagerequest.Fit(form.Get("fit"))
err = imgcache.ValidateFitMode(fitMode) err = imagerequest.ValidateFitMode(fitMode)
if err != nil { if err != nil {
return nil, time.Time{}, 0, return nil, time.Time{}, 0,
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit")) fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, form.Get("fit"))
} }
var ( var expiresAt time.Time
expiresAt time.Time
expiresAtUnix int64
)
if ttl > 0 { if ttl > 0 {
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second) expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
expiresAtUnix = expiresAt.Unix()
} }
payload := &encurl.Payload{ req := &imagerequest.Request{
SourceHost: parsed.Host, Source: imagerequest.Source{
SourcePath: parsed.Path, Host: parsed.Host,
SourceQuery: parsed.RawQuery, Path: parsed.Path,
Width: width, Query: parsed.RawQuery,
Height: height, },
Format: imgcache.ImageFormat(form.Get("format")), Transform: imagerequest.Transform{
Quality: quality, Size: imagerequest.Size{Width: width, Height: height},
FitMode: fitMode, Format: imagerequest.Format(form.Get("format")),
ExpiresAt: expiresAtUnix, Quality: quality,
FitMode: fitMode,
},
Expires: expiresAt,
} }
return payload, expiresAt, ttl, nil return req, expiresAt, ttl, nil
} }
// parseFormDimension reads an optional width or height form field. An empty // parseFormDimension reads an optional width or height form field. An empty
// value means "original size" (0). A non-numeric value, or one // value means "original size" (0). A non-numeric value, or one
// imgcache.ValidateDimension rejects, is an error naming the field. // imagerequest.ValidateDimension rejects, is an error naming the field.
func parseFormDimension(form url.Values, field string) (int, error) { func parseFormDimension(form url.Values, field string) (int, error) {
raw := form.Get(field) raw := form.Get(field)
if raw == "" { if raw == "" {
@@ -316,7 +314,7 @@ func parseFormDimension(form url.Values, field string) (int, error) {
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field) return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
} }
err = imgcache.ValidateDimension(field, value) err = imagerequest.ValidateDimension(field, value)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -11,7 +11,7 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// Generator form field names, and a value that is not a number. // Generator form field names, and a value that is not a number.
@@ -125,10 +125,10 @@ func TestGeneratePostRejectsBadField(t *testing.T) {
} }
} }
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality // TestBuildGenerateRequestDefaultAndLimits verifies that an empty quality
// takes the default, and that the largest accepted width, height, quality and // takes the default, and that the largest accepted width, height, quality and
// ttl are accepted with an expiry still in the future. // ttl are accepted with an expiry still in the future.
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) { func TestBuildGenerateRequestDefaultAndLimits(t *testing.T) {
t.Parallel() t.Parallel()
parsed, err := url.Parse(testSourceURL) parsed, err := url.Parse(testSourceURL)
@@ -136,19 +136,19 @@ func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
t.Fatalf("url.Parse() error = %v", err) t.Fatalf("url.Parse() error = %v", err)
} }
payload, _, _, err := buildGeneratePayload(parsed, url.Values{}) req, _, _, err := buildGenerateRequest(parsed, url.Values{})
if err != nil { if err != nil {
t.Fatalf("empty form: error = %v", err) t.Fatalf("empty form: error = %v", err)
} }
if payload.Quality != encurl.DefaultQuality { if req.Transform.Quality != encurl.DefaultQuality {
t.Errorf("empty quality gave %d, want %d", t.Errorf("empty quality gave %d, want %d",
payload.Quality, encurl.DefaultQuality) req.Transform.Quality, encurl.DefaultQuality)
} }
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{ _, expiresAt, _, err := buildGenerateRequest(parsed, url.Values{
widthField: {strconv.Itoa(imgcache.MaxDimension)}, widthField: {strconv.Itoa(imagerequest.MaxDimension)},
heightField: {strconv.Itoa(imgcache.MaxDimension)}, heightField: {strconv.Itoa(imagerequest.MaxDimension)},
qualityField: {strconv.Itoa(maxQuality)}, qualityField: {strconv.Itoa(maxQuality)},
ttlField: {strconv.Itoa(maxTTL)}, ttlField: {strconv.Itoa(maxTTL)},
}) })
@@ -10,7 +10,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/session" "sneak.berlin/go/pixa/internal/session"
) )
@@ -314,7 +314,7 @@ func TestGeneratePost_URLServesImage(t *testing.T) {
sourceURLField: {"https://" + signedHost + photoPath}, sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"}, widthField: {"50"},
heightField: {"50"}, heightField: {"50"},
formatField: {string(imgcache.FormatJPEG)}, formatField: {string(imagerequest.FormatJPEG)},
}) })
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -347,7 +347,7 @@ func TestGeneratePost_URLWithTTLExpires(t *testing.T) {
sourceURLField: {"https://" + signedHost + photoPath}, sourceURLField: {"https://" + signedHost + photoPath},
widthField: {"50"}, widthField: {"50"},
heightField: {"50"}, heightField: {"50"},
formatField: {string(imgcache.FormatJPEG)}, formatField: {string(imagerequest.FormatJPEG)},
ttlField: {"1"}, ttlField: {"1"},
}) })
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and // TestAVIFOutputTooLargeAnswers400 checks that respondImageError and
@@ -26,7 +26,7 @@ func TestAVIFOutputTooLargeAnswers400(t *testing.T) {
wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels) wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels)
plain := httptest.NewRecorder() plain := httptest.NewRecorder()
h.respondImageError(plain, &imgcache.ImageRequest{}, err) h.respondImageError(plain, &imagerequest.Request{}, err)
encrypted := httptest.NewRecorder() encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err) h.handleImageError(encrypted, err)
+10 -6
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
@@ -75,12 +76,15 @@ func mismatchedSourcePlainURL(*testing.T, *Handlers) string {
func mismatchedSourceEncURL(t *testing.T, h *Handlers) string { func mismatchedSourceEncURL(t *testing.T, h *Handlers) string {
t.Helper() t.Helper()
token, err := h.encGen.Generate(&encurl.Payload{ token, err := h.encGen.Generate(&imagerequest.Request{
SourceHost: allowlistedHost, Source: imagerequest.Source{
SourcePath: mismatchedSourcePath, Host: allowlistedHost,
Width: 50, Path: mismatchedSourcePath,
Height: 50, },
Format: imgcache.FormatJPEG, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
+10 -10
View File
@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// Errors for an Accept header that an auto URL cannot be served for. // Errors for an Accept header that an auto URL cannot be served for.
@@ -24,9 +24,9 @@ var (
// header that is not valid and 406 for one that allows none of the formats, // header that is not valid and 406 for one that allows none of the formats,
// and reports whether req can be served. Any other format is left as it is. // and reports whether req can be served. Any other format is left as it is.
func (s *Handlers) chooseAutoFormat( func (s *Handlers) chooseAutoFormat(
w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest, w http.ResponseWriter, r *http.Request, req *imagerequest.Request,
) bool { ) bool {
if req.Format != imgcache.FormatAuto { if req.Transform.Format != imagerequest.FormatAuto {
return true return true
} }
@@ -45,7 +45,7 @@ func (s *Handlers) chooseAutoFormat(
return false return false
} }
req.Format = format req.Transform.Format = format
return true return true
} }
@@ -56,26 +56,26 @@ func (s *Handlers) chooseAutoFormat(
// specific entry of image/jpeg, image/* and */* allows it, or when it names // specific entry of image/jpeg, image/* and */* allows it, or when it names
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as // nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
// clients that cannot show them send image/* and */* too. // clients that cannot show them send image/* and */* too.
func formatForAccept(accept string) (imgcache.ImageFormat, error) { func formatForAccept(accept string) (imagerequest.Format, error) {
qualities, err := parseAccept(accept) qualities, err := parseAccept(accept)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(qualities) == 0 { if len(qualities) == 0 {
return imgcache.FormatJPEG, nil return imagerequest.FormatJPEG, nil
} }
if qualities["image/jxl"] > 0 { if qualities["image/jxl"] > 0 {
return imgcache.FormatJXL, nil return imagerequest.FormatJXL, nil
} }
if qualities["image/avif"] > 0 { if qualities["image/avif"] > 0 {
return imgcache.FormatAVIF, nil return imagerequest.FormatAVIF, nil
} }
if qualities["image/webp"] > 0 { if qualities["image/webp"] > 0 {
return imgcache.FormatWebP, nil return imagerequest.FormatWebP, nil
} }
// For JPEG, the most specific entry the header has decides // For JPEG, the most specific entry the header has decides
@@ -89,7 +89,7 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) {
} }
if quality > 0 { if quality > 0 {
return imgcache.FormatJPEG, nil return imagerequest.FormatJPEG, nil
} }
return "", errNotAcceptable return "", errNotAcceptable
+40 -30
View File
@@ -12,8 +12,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
) )
// The content types the tests below expect. // The content types the tests below expect.
@@ -33,24 +32,24 @@ func TestFormatForAccept(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
accept string accept string
want imgcache.ImageFormat want imagerequest.Format
wantErr error wantErr error
}{ }{
{"AVIF-capable browser", {"AVIF-capable browser",
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
imgcache.FormatAVIF, nil}, imagerequest.FormatAVIF, nil},
{"WebP-capable browser", {"WebP-capable browser",
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5", "image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
imgcache.FormatWebP, nil}, imagerequest.FormatWebP, nil},
{"WebP only", webpType, imgcache.FormatWebP, nil}, {"WebP only", webpType, imagerequest.FormatWebP, nil},
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil}, {"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imagerequest.FormatJPEG, nil},
{"wildcard only", "*/*", imgcache.FormatJPEG, nil}, {"wildcard only", "*/*", imagerequest.FormatJPEG, nil},
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil}, {"image wildcard only", "image/*", imagerequest.FormatJPEG, nil},
{"absent", "", imgcache.FormatJPEG, nil}, {"absent", "", imagerequest.FormatJPEG, nil},
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil}, {"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imagerequest.FormatWebP, nil},
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*", {"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
imgcache.FormatJPEG, nil}, imagerequest.FormatJPEG, nil},
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil}, {"upper case and spaces", " Image/AVIF ; Q=0.5 ", imagerequest.FormatAVIF, nil},
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable}, {"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable}, {"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
{"PNG only", "image/png", "", errNotAcceptable}, {"PNG only", "image/png", "", errNotAcceptable},
@@ -80,23 +79,30 @@ func TestFormatForAccept(t *testing.T) {
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) { func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
t.Helper() t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Size: imgcache.Size{Width: 50, Height: 50}, Path: photoPath,
Format: imgcache.FormatAuto, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatAuto,
},
}, time.Minute) }, time.Minute)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
} }
token, err := h.encGen.Generate(&encurl.Payload{ token, err := h.encGen.Generate(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Width: 50, Path: photoPath,
Height: 50, },
Format: imgcache.FormatAuto, Transform: imagerequest.Transform{
ExpiresAt: time.Now().Add(time.Minute).Unix(), Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatAuto,
},
Expires: time.Now().Add(time.Minute),
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
@@ -184,11 +190,15 @@ func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Size: imgcache.Size{Width: 50, Height: 50}, Path: photoPath,
Format: imgcache.FormatAVIF, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatAVIF,
},
}, time.Minute) }, time.Minute)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
+19 -21
View File
@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
) )
@@ -35,8 +36,8 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
err := s.imgSvc.ValidateRequest(req) err := s.imgSvc.ValidateRequest(req)
if err != nil { if err != nil {
s.log.Warn("signature validation failed", s.log.Warn("signature validation failed",
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
"error", err, "error", err,
) )
s.respondError(w, "unauthorized", http.StatusUnauthorized) s.respondError(w, "unauthorized", http.StatusUnauthorized)
@@ -50,7 +51,7 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
} }
// Get cache key for logging // Get cache key for logging
cacheKey := imgcache.CacheKey(req) cacheKey := imagerequest.CacheKey(req)
// Get the image (from cache or fetch/process) // Get the image (from cache or fetch/process)
startTime := time.Now() startTime := time.Now()
@@ -81,17 +82,17 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
} }
// parseImageRequest parses the image URL's path and query parameters into // parseImageRequest parses the image URL's path and query parameters into
// an ImageRequest. On invalid input it writes an error response and // an image request. On invalid input it writes an error response and
// returns false. // returns false.
func (s *Handlers) parseImageRequest( func (s *Handlers) parseImageRequest(
w http.ResponseWriter, r *http.Request, w http.ResponseWriter, r *http.Request,
) (*imgcache.ImageRequest, bool) { ) (*imagerequest.Request, bool) {
// The path is parsed decoded once, as r.URL.Path holds it, and not from // The path is parsed decoded once, as r.URL.Path holds it, and not from
// chi's wildcard: chi routes on the path as the client wrote it whenever // chi's wildcard: chi routes on the path as the client wrote it whenever
// that is not Go's own encoding of it, so its wildcard would still hold // that is not Go's own encoding of it, so its wildcard would still hold
// the %3F before a source query when, say, a "/" in the query is written // the %3F before a source query when, say, a "/" in the query is written
// as %2F // as %2F
parsed, err := imgcache.ParseImageURL(r.URL.Path) req, err := imagerequest.ParseImageURL(r.URL.Path)
if err != nil { if err != nil {
s.log.Warn("failed to parse image URL", s.log.Warn("failed to parse image URL",
"path", r.URL.Path, "path", r.URL.Path,
@@ -102,9 +103,6 @@ func (s *Handlers) parseImageRequest(
return nil, false return nil, false
} }
// Convert to ImageRequest
req := parsed.ToImageRequest()
// Parse signature params from query string. r.URL.Query() would silently // Parse signature params from query string. r.URL.Query() would silently
// drop a pair it cannot decode, such as q=80%, so that q would be served // drop a pair it cannot decode, such as q=80%, so that q would be served
// at 85; a query string that cannot be decoded is refused instead. A // at 85; a query string that cannot be decoded is refused instead. A
@@ -148,7 +146,7 @@ func (s *Handlers) parseImageRequest(
return nil, false return nil, false
} }
req.Quality, err = parseFormInt(query, "q", req.Transform.Quality, err = parseFormInt(query, "q",
encurl.DefaultQuality, minQuality, maxQuality) encurl.DefaultQuality, minQuality, maxQuality)
if err != nil { if err != nil {
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr), s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
@@ -160,23 +158,23 @@ func (s *Handlers) parseImageRequest(
// Only a fit missing from the URL is cover. A fit in the URL that is not a // Only a fit missing from the URL is cover. A fit in the URL that is not a
// fit mode is refused by the fit-mode check below; that check would take an // fit mode is refused by the fit-mode check below; that check would take an
// empty fit as missing, so an empty one is refused here. // empty fit as missing, so an empty one is refused here.
req.FitMode = imgcache.FitMode(query.Get("fit")) req.Transform.FitMode = imagerequest.Fit(query.Get("fit"))
if query.Has("fit") && req.FitMode == "" { if query.Has("fit") && req.Transform.FitMode == "" {
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest) s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
return nil, false return nil, false
} }
// Default fit mode if not set // Default fit mode if not set
if req.FitMode == "" { if req.Transform.FitMode == "" {
req.FitMode = imgcache.FitCover req.Transform.FitMode = imagerequest.FitCover
} }
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL // Enforce dimension and fit-mode bounds, shared with the encrypted-URL
// route. Dimensions are already bounded by the path parser above; this // route. Dimensions are already bounded by the path parser above; this
// also rejects an unrecognized fit mode with 400 instead of letting it // also rejects an unrecognized fit mode with 400 instead of letting it
// reach the processor as a 500. // reach the processor as a 500.
err = imgcache.ValidateImageRequest(req) err = imagerequest.ValidateImageRequest(req)
if err != nil { if err != nil {
s.respondError(w, "invalid image request: "+err.Error(), s.respondError(w, "invalid image request: "+err.Error(),
http.StatusBadRequest) http.StatusBadRequest)
@@ -219,7 +217,7 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
// respondImageError maps image retrieval errors to HTTP responses. // respondImageError maps image retrieval errors to HTTP responses.
func (s *Handlers) respondImageError( func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imgcache.ImageRequest, err error, w http.ResponseWriter, req *imagerequest.Request, err error,
) { ) {
// An upstream body whose first bytes are not those of the format its // An upstream body whose first bytes are not those of the format its
// Content-Type names is the upstream's error, not pixa's: the image // Content-Type names is the upstream's error, not pixa's: the image
@@ -234,8 +232,8 @@ func (s *Handlers) respondImageError(
} }
s.log.Error("failed to get image", s.log.Error("failed to get image",
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
"error", err, "error", err,
) )
@@ -346,8 +344,8 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
// handling conditional and HEAD requests. // handling conditional and HEAD requests.
func (s *Handlers) writeImageResponse( func (s *Handlers) writeImageResponse(
w http.ResponseWriter, r *http.Request, w http.ResponseWriter, r *http.Request,
req *imgcache.ImageRequest, resp *imgcache.ImageResponse, req *imagerequest.Request, resp *imgcache.ImageResponse,
cacheKey imgcache.VariantKey, startTime time.Time, cacheKey imagerequest.VariantKey, startTime time.Time,
) { ) {
// Set response headers // Set response headers
w.Header().Set("Content-Type", resp.ContentType) w.Header().Set("Content-Type", resp.ContentType)
@@ -388,7 +386,7 @@ func (s *Handlers) writeImageResponse(
"cache_key", cacheKey, "cache_key", cacheKey,
"cache_status", resp.CacheStatus, "cache_status", resp.CacheStatus,
"duration_ms", duration.Milliseconds(), "duration_ms", duration.Milliseconds(),
"format", req.Format, "format", req.Transform.Format,
"served_bytes", servedBytes, "served_bytes", servedBytes,
"fetched_bytes", resp.FetchedBytes, "fetched_bytes", resp.FetchedBytes,
) )
@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
@@ -107,11 +108,15 @@ func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Size: imgcache.Size{Width: 50, Height: 50}, Path: photoPath,
Format: imgcache.FormatJPEG, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Minute) }, time.Minute)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
@@ -168,13 +173,13 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
expiresAt int64 expires time.Time
wantAtLeast int wantAtLeast int
wantAtMost int wantAtMost int
}{ }{
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60}, {"60 second TTL", time.Now().Add(time.Minute), 50, 60},
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000}, {"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour), 31536000, 31536000},
{"no TTL", 0, 31536000, 31536000}, {"no TTL", time.Time{}, 31536000, 31536000},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -183,13 +188,16 @@ func TestHandleImageEnc_MaxAge(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
token, err := h.encGen.Generate(&encurl.Payload{ token, err := h.encGen.Generate(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Width: 50, Path: photoPath,
Height: 50, },
Format: imgcache.FormatJPEG, Transform: imagerequest.Transform{
ExpiresAt: tt.expiresAt, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
Expires: tt.expires,
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
+25 -18
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -84,15 +85,18 @@ func photoURLWithSig(host, sig string, expires time.Time) string {
// photoSignature returns the signature of photoURL(host) at the default // photoSignature returns the signature of photoURL(host) at the default
// quality and fit, made with key and expiring at expires. // quality and fit, made with key and expiring at expires.
func photoSignature(key, host string, expires time.Time) string { func photoSignature(key, host string, expires time.Time) string {
return signature.New(key).Sign(&signature.Request{ return signature.New(key).Sign(&imagerequest.Request{
SourceHost: host, Source: imagerequest.Source{
SourcePath: photoPath, Host: host,
Width: 50, Path: photoPath,
Height: 50, },
Format: string(imgcache.FormatJPEG), Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 50, Height: 50},
FitMode: string(imgcache.FitCover), Format: imagerequest.FormatJPEG,
Expires: expires, Quality: 85,
FitMode: imagerequest.FitCover,
},
Expires: expires,
}) })
} }
@@ -353,15 +357,18 @@ func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
photos := newPhotoFetcher(t, allowlistedHost, signedHost) photos := newPhotoFetcher(t, allowlistedHost, signedHost)
exp := time.Now().Add(time.Hour) exp := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(&signature.Request{ sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath + "#x", Host: signedHost,
Width: 50, Path: photoPath + "#x",
Height: 50, },
Format: string(imgcache.FormatJPEG), Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 50, Height: 50},
FitMode: string(imgcache.FitCover), Format: imagerequest.FormatJPEG,
Expires: exp, Quality: 85,
FitMode: imagerequest.FitCover,
},
Expires: exp,
}) })
tests := []struct { tests := []struct {
@@ -9,7 +9,7 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -44,15 +44,18 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
expires := time.Now().Add(time.Hour) expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars") signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&signature.Request{ sig := signer.Sign(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: "/images/photo.jpg", Host: signedHost,
Width: 50, Path: "/images/photo.jpg",
Height: 50, },
Format: string(imgcache.FormatJPEG), Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 50, Height: 50},
FitMode: string(imgcache.FitCover), Format: imagerequest.FormatJPEG,
Expires: expires, Quality: 85,
FitMode: imagerequest.FitCover,
},
Expires: expires,
}) })
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d", signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
signedHost, sig, expires.Unix()) signedHost, sig, expires.Unix())
@@ -88,9 +91,9 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
quality int quality int
fitMode imgcache.FitMode fitMode imagerequest.Fit
}{ }{
{"quality 40 and fit contain", 40, imgcache.FitContain}, {"quality 40 and fit contain", 40, imagerequest.FitContain},
{"quality and fit unset", 0, ""}, {"quality and fit unset", 0, ""},
} }
@@ -100,13 +103,17 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
fix := setupTestHandler(t) fix := setupTestHandler(t)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{ signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: "/images/photo.jpg", Host: signedHost,
Size: imgcache.Size{Width: 50, Height: 50}, Path: "/images/photo.jpg",
Format: imgcache.FormatJPEG, },
Quality: tt.quality, Transform: imagerequest.Transform{
FitMode: tt.fitMode, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: tt.quality,
FitMode: tt.fitMode,
},
}, time.Hour) }, time.Hour)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
@@ -196,12 +203,16 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
fix := setupTestHandler(t) fix := setupTestHandler(t)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{ signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
SourceQuery: tt.query, Path: photoPath,
Size: imgcache.Size{Width: 50, Height: 50}, Query: tt.query,
Format: imgcache.FormatJPEG, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Hour) }, time.Hour)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
@@ -224,16 +235,19 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
expires := time.Now().Add(time.Hour) expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars") signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&signature.Request{ sig := signer.Sign(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
SourceQuery: "url=https://origin.example.com/my%20cat.jpg", Path: photoPath,
Width: 50, Query: "url=https://origin.example.com/my%20cat.jpg",
Height: 50, },
Format: string(imgcache.FormatJPEG), Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 50, Height: 50},
FitMode: string(imgcache.FitCover), Format: imagerequest.FormatJPEG,
Expires: expires, Quality: 85,
FitMode: imagerequest.FitCover,
},
Expires: expires,
}) })
tests := []struct { tests := []struct {
@@ -288,16 +302,19 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
expires := time.Now().Add(time.Hour) expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars") signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&signature.Request{ sig := signer.Sign(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: tt.sourcePath, Host: signedHost,
SourceQuery: tt.sourceQuery, Path: tt.sourcePath,
Width: 50, Query: tt.sourceQuery,
Height: 50, },
Format: string(imgcache.FormatJPEG), Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 50, Height: 50},
FitMode: string(imgcache.FitCover), Format: imagerequest.FormatJPEG,
Expires: expires, Quality: 85,
FitMode: imagerequest.FitCover,
},
Expires: expires,
}) })
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d", target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
@@ -314,12 +331,16 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
fix := setupTestHandler(t) fix := setupTestHandler(t)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{ signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: tt.sourcePath, Host: signedHost,
SourceQuery: tt.sourceQuery, Path: tt.sourcePath,
Size: imgcache.Size{Width: 50, Height: 50}, Query: tt.sourceQuery,
Format: imgcache.FormatJPEG, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Hour) }, time.Hour)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
+14 -15
View File
@@ -14,6 +14,7 @@ import (
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
) )
@@ -37,10 +38,11 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
// Log the request // Log the request
s.log.Debug("encrypted image request", s.log.Debug("encrypted image request",
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
"dimensions", fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height), "dimensions", fmt.Sprintf("%dx%d",
"format", req.Format, req.Transform.Size.Width, req.Transform.Size.Height),
"format", req.Transform.Format,
) )
// Fetch and process the image (no signature validation // Fetch and process the image (no signature validation
@@ -88,10 +90,10 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
duration := time.Since(start) duration := time.Since(start)
s.log.Info("image served", s.log.Info("image served",
"request_id", middleware.GetReqID(ctx), "request_id", middleware.GetReqID(ctx),
"cache_key", imgcache.CacheKey(req), "cache_key", imagerequest.CacheKey(req),
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
"format", req.Format, "format", req.Transform.Format,
"cache_status", resp.CacheStatus, "cache_status", resp.CacheStatus,
"served_bytes", written, "served_bytes", written,
"duration_ms", duration.Milliseconds(), "duration_ms", duration.Milliseconds(),
@@ -100,12 +102,12 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
} }
// parseImageEncRequest decrypts the token of an encrypted image URL into an // parseImageEncRequest decrypts the token of an encrypted image URL into an
// ImageRequest and checks it. On a token that is missing, does not decrypt, // image request and checks it. On a token that is missing, does not decrypt,
// has expired or asks for something not valid, it writes an error response // has expired or asks for something not valid, it writes an error response
// and returns false. // and returns false.
func (s *Handlers) parseImageEncRequest( func (s *Handlers) parseImageEncRequest(
w http.ResponseWriter, r *http.Request, w http.ResponseWriter, r *http.Request,
) (*imgcache.ImageRequest, bool) { ) (*imagerequest.Request, bool) {
// Extract token from URL // Extract token from URL
token := chi.URLParam(r, "token") token := chi.URLParam(r, "token")
if token == "" { if token == "" {
@@ -115,7 +117,7 @@ func (s *Handlers) parseImageEncRequest(
} }
// Decrypt and validate the payload // Decrypt and validate the payload
payload, err := s.encGen.Parse(token) req, err := s.encGen.Parse(token)
if err != nil { if err != nil {
if errors.Is(err, encurl.ErrExpired) { if errors.Is(err, encurl.ErrExpired) {
s.log.Debug("encrypted URL expired", "error", err) s.log.Debug("encrypted URL expired", "error", err)
@@ -130,14 +132,11 @@ func (s *Handlers) parseImageEncRequest(
return nil, false return nil, false
} }
// Convert payload to ImageRequest
req := payload.ToImageRequest()
// Apply the same dimension and fit-mode bounds as the plain image // Apply the same dimension and fit-mode bounds as the plain image
// route: a sealed payload is trusted for its origin, not for staying // route: a sealed payload is trusted for its origin, not for staying
// within limits, so an over-limit size or unknown fit mode is a 400 // within limits, so an over-limit size or unknown fit mode is a 400
// here rather than an out-of-memory or a 500 from the processor. // here rather than an out-of-memory or a 500 from the processor.
err = imgcache.ValidateImageRequest(req) err = imagerequest.ValidateImageRequest(req)
if err != nil { if err != nil {
s.log.Debug("encrypted URL failed validation", "error", err) s.log.Debug("encrypted URL failed validation", "error", err)
s.respondError(w, "invalid encrypted URL: "+err.Error(), s.respondError(w, "invalid encrypted URL: "+err.Error(),
+18 -12
View File
@@ -10,7 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// newEncTestServer builds a router serving the encrypted-URL route with a // newEncTestServer builds a router serving the encrypted-URL route with a
@@ -54,11 +54,14 @@ func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
encGen, srv := newEncTestServer(t) encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&encurl.Payload{ token, err := encGen.Generate(&imagerequest.Request{
SourceHost: "cdn.example.com", Source: imagerequest.Source{
SourcePath: "/photo.jpg", Host: "cdn.example.com",
Width: 100000, Path: "/photo.jpg",
Height: 100000, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 100000, Height: 100000},
},
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
@@ -79,12 +82,15 @@ func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
encGen, srv := newEncTestServer(t) encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&encurl.Payload{ token, err := encGen.Generate(&imagerequest.Request{
SourceHost: "cdn.example.com", Source: imagerequest.Source{
SourcePath: "/photo.jpg", Host: "cdn.example.com",
Width: 800, Path: "/photo.jpg",
Height: 600, },
FitMode: imgcache.FitMode("bogus"), Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: imagerequest.Fit("bogus"),
},
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
@@ -9,6 +9,7 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath // requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
@@ -59,11 +60,14 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
photo := encurl.Payload{ photo := imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Width: 50, Path: photoPath,
Height: 50, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
},
} }
valid, err := h.encGen.Generate(&photo) valid, err := h.encGen.Generate(&photo)
@@ -72,7 +76,7 @@ func TestHandleImageEnc_RejectedToken(t *testing.T) {
} }
expiredPhoto := photo expiredPhoto := photo
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix() expiredPhoto.Expires = time.Now().Add(-time.Minute)
expired, err := h.encGen.Generate(&expiredPhoto) expired, err := h.encGen.Generate(&expiredPhoto)
if err != nil { if err != nil {
@@ -14,7 +14,7 @@ import (
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -58,15 +58,18 @@ func TestImageWithoutFormat_SignedAsJXL(t *testing.T) {
route := newImageRoute(t, newPhotoFetcher(t, signedHost)) route := newImageRoute(t, newPhotoFetcher(t, signedHost))
expires := time.Now().Add(time.Hour) expires := time.Now().Add(time.Hour)
sig := signature.New(testSigningKey).Sign(&signature.Request{ sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Width: 50, Path: photoPath,
Height: 50, },
Format: string(imgcache.FormatJXL), Transform: imagerequest.Transform{
Quality: encurl.DefaultQuality, Size: imagerequest.Size{Width: 50, Height: 50},
FitMode: string(imgcache.FitCover), Format: imagerequest.FormatJXL,
Expires: expires, Quality: encurl.DefaultQuality,
FitMode: imagerequest.FitCover,
},
Expires: expires,
}) })
query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix()) query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix())
@@ -83,11 +86,14 @@ func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) {
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
token, err := h.encGen.Generate(&encurl.Payload{ token, err := h.encGen.Generate(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Width: 50, Path: photoPath,
Height: 50, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
},
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
+6 -6
View File
@@ -7,7 +7,7 @@ import (
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// jxlType is the content type of JPEG XL. // jxlType is the content type of JPEG XL.
@@ -22,14 +22,14 @@ func TestFormatForAccept_JPEGXL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
accept string accept string
want imgcache.ImageFormat want imagerequest.Format
}{ }{
{"JPEG XL-capable browser", {"JPEG XL-capable browser",
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL}, "image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imagerequest.FormatJXL},
{"JPEG XL only", jxlType, imgcache.FormatJXL}, {"JPEG XL only", jxlType, imagerequest.FormatJXL},
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5", {"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
imgcache.FormatJXL}, imagerequest.FormatJXL},
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF}, {"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imagerequest.FormatAVIF},
} }
for _, tt := range tests { for _, tt := range tests {
+19 -13
View File
@@ -13,8 +13,7 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
) )
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the // signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
@@ -22,11 +21,15 @@ import (
func signedPhotoURL(t *testing.T, h *Handlers) string { func signedPhotoURL(t *testing.T, h *Handlers) string {
t.Helper() t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Size: imgcache.Size{Width: 50, Height: 50}, Path: photoPath,
Format: imgcache.FormatJPEG, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}, time.Minute) }, time.Minute)
if err != nil { if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err) t.Fatalf("GenerateSignedURL() error = %v", err)
@@ -40,12 +43,15 @@ func signedPhotoURL(t *testing.T, h *Handlers) string {
func encPhotoURL(t *testing.T, h *Handlers) string { func encPhotoURL(t *testing.T, h *Handlers) string {
t.Helper() t.Helper()
token, err := h.encGen.Generate(&encurl.Payload{ token, err := h.encGen.Generate(&imagerequest.Request{
SourceHost: signedHost, Source: imagerequest.Source{
SourcePath: photoPath, Host: signedHost,
Width: 50, Path: photoPath,
Height: 50, },
Format: imgcache.FormatJPEG, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
},
}) })
if err != nil { if err != nil {
t.Fatalf("Generate() error = %v", err) t.Fatalf("Generate() error = %v", err)
@@ -11,6 +11,7 @@ import (
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
@@ -21,7 +22,9 @@ func TestServerBusyAnswers503(t *testing.T) {
t.Parallel() t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)} h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.jpg"} req := &imagerequest.Request{
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.jpg"},
}
for _, err := range []error{ for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections), fmt.Errorf("upstream fetch failed: %w", httpfetcher.ErrTooManyConnections),
@@ -64,7 +67,7 @@ func TestProcessingTimedOutAnswers503(t *testing.T) {
imgcache.ErrProcessingTimedOut, context.DeadlineExceeded) imgcache.ErrProcessingTimedOut, context.DeadlineExceeded)
plain := httptest.NewRecorder() plain := httptest.NewRecorder()
h.respondImageError(plain, &imgcache.ImageRequest{}, err) h.respondImageError(plain, &imagerequest.Request{}, err)
encrypted := httptest.NewRecorder() encrypted := httptest.NewRecorder()
h.handleImageError(encrypted, err) h.handleImageError(encrypted, err)
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestUnsupportedSourceFormatAnswers415 checks that both image routes answer // TestUnsupportedSourceFormatAnswers415 checks that both image routes answer
@@ -21,7 +21,9 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
t.Parallel() t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)} h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imgcache.ImageRequest{SourceHost: "img.example.com", SourcePath: "/a.svg"} req := &imagerequest.Request{
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.svg"},
}
for _, err := range []error{ for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w: image/svg+xml", fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
@@ -6,6 +6,8 @@ import (
"errors" "errors"
"os" "os"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output // TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output
@@ -37,22 +39,30 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input []byte input []byte
format Format format imagerequest.Format
width, height int width, height int
fit FitMode fit imagerequest.Fit
refused bool refused bool
}{ }{
{"AVIF at the limit", cutOff, FormatAVIF, 4096, 4096, FitCover, false}, {"AVIF at the limit", cutOff, imagerequest.FormatAVIF,
{"AVIF above the limit", cutOff, FormatAVIF, 4096, 4097, FitCover, true}, 4096, 4096, imagerequest.FitCover, false},
{"WebP above the AVIF limit", cutOff, FormatWebP, 4096, 4097, FitCover, false}, {"AVIF above the limit", cutOff, imagerequest.FormatAVIF,
4096, 4097, imagerequest.FitCover, true},
{"WebP above the AVIF limit", cutOff, imagerequest.FormatWebP,
4096, 4097, imagerequest.FitCover, false},
// FitInside does not enlarge, so the output stays 64x32 // FitInside does not enlarge, so the output stays 64x32
{"AVIF inside a larger size", cutOff, FormatAVIF, 8192, 8192, FitInside, false}, {"AVIF inside a larger size", cutOff, imagerequest.FormatAVIF,
8192, 8192, imagerequest.FitInside, false},
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096 // FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
{"AVIF outside a smaller size", cutOff, FormatAVIF, 8192, 1, FitOutside, true}, {"AVIF outside a smaller size", cutOff, imagerequest.FormatAVIF,
8192, 1, imagerequest.FitOutside, true},
// With no size, the output keeps the source's 4097x4096 // With no size, the output keeps the source's 4097x4096
{"AVIF of the source's size", largeAVIF, FormatAVIF, 0, 0, FitCover, true}, {"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
{"orig of an AVIF source", largeAVIF, FormatOriginal, 0, 0, FitCover, true}, 0, 0, imagerequest.FitCover, true},
{"JPEG of the source's size", largeAVIF, FormatJPEG, 0, 0, FitCover, false}, {"orig of an AVIF source", largeAVIF, imagerequest.FormatOriginal,
0, 0, imagerequest.FitCover, true},
{"JPEG of the source's size", largeAVIF, imagerequest.FormatJPEG,
0, 0, imagerequest.FitCover, false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -60,8 +70,8 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
t.Parallel() t.Parallel()
_, err := New(Params{}).Process(t.Context(), bytes.NewReader(tt.input), _, err := New(Params{}).Process(t.Context(), bytes.NewReader(tt.input),
&Request{ &imagerequest.Transform{
Size: Size{Width: tt.width, Height: tt.height}, Size: imagerequest.Size{Width: tt.width, Height: tt.height},
Format: tt.format, Format: tt.format,
FitMode: tt.fit, FitMode: tt.fit,
}) })
@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"errors" "errors"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size // TestImageProcessor_RefusesComputedSideAboveMaximum verifies that a size
@@ -17,13 +19,17 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
inputWidth, inputHeight int inputWidth, inputHeight int
size Size size imagerequest.Size
wantErr error wantErr error
}{ }{
{"width at the maximum", 4096, 1, Size{Height: 2}, nil}, {"width at the maximum", 4096, 1,
{"width above the maximum", 4096, 1, Size{Height: 3}, ErrOutputTooLarge}, imagerequest.Size{Height: 2}, nil},
{"height at the maximum", 1, 4096, Size{Width: 2}, nil}, {"width above the maximum", 4096, 1,
{"height above the maximum", 1, 4096, Size{Width: 3}, ErrOutputTooLarge}, imagerequest.Size{Height: 3}, ErrOutputTooLarge},
{"height at the maximum", 1, 4096,
imagerequest.Size{Width: 2}, nil},
{"height above the maximum", 1, 4096,
imagerequest.Size{Width: 3}, ErrOutputTooLarge},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -34,7 +40,11 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover}) &imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitCover,
})
if result != nil { if result != nil {
t.Logf("Process() made %dx%d", result.Width, result.Height) t.Logf("Process() made %dx%d", result.Width, result.Height)
@@ -64,18 +74,21 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
inputWidth, inputHeight int inputWidth, inputHeight int
size Size size imagerequest.Size
wantWidth, wantHeight int wantWidth, wantHeight int
}{ }{
{"8192x1 at 100x0", 8192, 1, Size{Width: 100}, 100, 1}, {"8192x1 at 100x0", 8192, 1, imagerequest.Size{Width: 100}, 100, 1},
{"8192x1 at 1300x0", 8192, 1, Size{Width: 1300}, 1300, 1}, {"8192x1 at 1300x0", 8192, 1, imagerequest.Size{Width: 1300}, 1300, 1},
{"8192x2 at 100x0", 8192, 2, Size{Width: 100}, 100, 1}, {"8192x2 at 100x0", 8192, 2, imagerequest.Size{Width: 100}, 100, 1},
{"1x8192 at 0x100", 1, 8192, Size{Height: 100}, 1, 100}, {"1x8192 at 0x100", 1, 8192, imagerequest.Size{Height: 100}, 1, 100},
{"1x8192 at 0x1300", 1, 8192, Size{Height: 1300}, 1, 1300}, {"1x8192 at 0x1300", 1, 8192, imagerequest.Size{Height: 1300}, 1, 1300},
{"2x8192 at 0x100", 2, 8192, Size{Height: 100}, 1, 100}, {"2x8192 at 0x100", 2, 8192, imagerequest.Size{Height: 100}, 1, 100},
} }
fits := []FitMode{FitCover, FitContain, FitFill, FitInside, FitOutside} fits := []imagerequest.Fit{
imagerequest.FitCover, imagerequest.FitContain, imagerequest.FitFill,
imagerequest.FitInside, imagerequest.FitOutside,
}
for _, tt := range tests { for _, tt := range tests {
for _, fit := range fits { for _, fit := range fits {
@@ -93,7 +106,11 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: fit}) &imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: fit,
})
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -103,14 +120,14 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
t.Logf("Process() made %dx%d", result.Width, result.Height) t.Logf("Process() made %dx%d", result.Width, result.Height)
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
if fit == FitOutside { if fit == imagerequest.FitOutside {
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
} }
if !ok { if !ok {
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)", t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
result.Width, result.Height, tt.wantWidth, tt.wantHeight, result.Width, result.Height, tt.wantWidth, tt.wantHeight,
FitOutside) imagerequest.FitOutside)
} }
}) })
} }
@@ -86,7 +86,7 @@ func checkProcessStopsAtDeadline(
proc := New(Params{MaxConcurrentProcessing: 1}) proc := New(Params{MaxConcurrentProcessing: 1})
result, err := proc.Process(ctx, input, smallJPEGRequest()) result, err := proc.Process(ctx, input, smallJPEGTransform())
if result != nil { if result != nil {
t.Logf("Process() made a %dx%d image after its deadline passed", t.Logf("Process() made a %dx%d image after its deadline passed",
result.Width, result.Height) result.Width, result.Height)
@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"errors" "errors"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_EmptyFormatRefused verifies that a request with no format // TestImageProcessor_EmptyFormatRefused verifies that a request with no format
@@ -13,7 +15,8 @@ func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
t.Parallel() t.Parallel()
_, err := New(Params{}).Process( _, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)), &Request{}, t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
&imagerequest.Transform{},
) )
if !errors.Is(err, ErrUnsupportedOutputFormat) { if !errors.Is(err, ErrUnsupportedOutputFormat) {
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat) t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
@@ -8,14 +8,17 @@ import (
"testing" "testing"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// processedSize runs input through Process and returns the output's size in // processedSize runs input through Process and returns the output's size in
// bytes. // bytes.
func processedSize(t *testing.T, input []byte, req *Request) int64 { func processedSize(
t *testing.T, input []byte, transform *imagerequest.Transform,
) int64 {
t.Helper() t.Helper()
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req) result, err := New(Params{}).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)
} }
@@ -68,7 +71,8 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
} }
} }
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG}) size := processedSize(t, encodePNG(t, flat),
&imagerequest.Transform{Format: imagerequest.FormatPNG})
const rawBytes = width * height * 3 const rawBytes = width * height * 3
if size > rawBytes/10 { if size > rawBytes/10 {
@@ -85,7 +89,8 @@ func TestImageProcessor_WebPAtEffort2(t *testing.T) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85}) size := processedSize(t, input,
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{ want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
StripMetadata: true, StripMetadata: true,
@@ -126,7 +131,8 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
input := encodePNG(t, source) input := encodePNG(t, source)
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85}) size := processedSize(t, input,
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{ want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
StripMetadata: true, StripMetadata: true,
@@ -152,7 +158,8 @@ func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
size := processedSize(t, input, &Request{Format: FormatJXL, Quality: 85}) size := processedSize(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
params := vips.NewJxlExportParams() params := vips.NewJxlExportParams()
params.Quality = 85 params.Quality = 85
@@ -7,6 +7,8 @@ import (
"image/png" "image/png"
"math" "math"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves // TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
@@ -20,12 +22,12 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
inputWidth, inputHeight int inputWidth, inputHeight int
size Size size imagerequest.Size
}{ }{
{"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}}, {"8192x1 at 8192x8192", 8192, 1, imagerequest.Size{Width: 8192, Height: 8192}},
{"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}}, {"8192x1 at 1x8192", 8192, 1, imagerequest.Size{Width: 1, Height: 8192}},
{"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}}, {"1x8192 at 8192x8192", 1, 8192, imagerequest.Size{Width: 8192, Height: 8192}},
{"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}}, {"1x8192 at 8192x1", 1, 8192, imagerequest.Size{Width: 8192, Height: 1}},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -36,7 +38,11 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover}) &imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitCover,
})
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -102,10 +108,10 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{ &imagerequest.Transform{
Size: Size{Width: 50, Height: 50}, Size: imagerequest.Size{Width: 50, Height: 50},
Format: FormatPNG, Format: imagerequest.FormatPNG,
FitMode: FitCover, FitMode: imagerequest.FitCover,
}) })
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
@@ -208,14 +214,14 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
side, squareSide int side, squareSide int
size Size size imagerequest.Size
}{ }{
{"16x16 at 100x60", 16, 8, Size{Width: 100, Height: 60}}, {"16x16 at 100x60", 16, 8, imagerequest.Size{Width: 100, Height: 60}},
{"16x16 at 60x100", 16, 8, Size{Width: 60, Height: 100}}, {"16x16 at 60x100", 16, 8, imagerequest.Size{Width: 60, Height: 100}},
{"10x10 at 100x35", 10, 2, Size{Width: 100, Height: 35}}, {"10x10 at 100x35", 10, 2, imagerequest.Size{Width: 100, Height: 35}},
{"10x10 at 35x100", 10, 2, Size{Width: 35, Height: 100}}, {"10x10 at 35x100", 10, 2, imagerequest.Size{Width: 35, Height: 100}},
{"64x64 at 300x100", 64, 16, Size{Width: 300, Height: 100}}, {"64x64 at 300x100", 64, 16, imagerequest.Size{Width: 300, Height: 100}},
{"64x64 at 100x300", 64, 16, Size{Width: 100, Height: 300}}, {"64x64 at 100x300", 64, 16, imagerequest.Size{Width: 100, Height: 300}},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -226,7 +232,11 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatPNG, FitMode: FitCover}) &imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatPNG,
FitMode: imagerequest.FitCover,
})
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"errors" "errors"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside, // TestImageProcessor_RefusesFitOutsideAboveMaximum verifies that FitOutside,
@@ -17,15 +19,20 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
inputWidth, inputHeight int inputWidth, inputHeight int
size Size size imagerequest.Size
// wantWidth and wantHeight are the output's, 0 for a refusal // wantWidth and wantHeight are the output's, 0 for a refusal
wantWidth, wantHeight int wantWidth, wantHeight int
}{ }{
{"width above the maximum", 3000, 600, Size{Width: 1, Height: 8192}, 0, 0}, {"width above the maximum", 3000, 600,
{"height above the maximum", 600, 3000, Size{Width: 8192, Height: 1}, 0, 0}, imagerequest.Size{Width: 1, Height: 8192}, 0, 0},
{"width at the maximum", 1024, 256, Size{Width: 1, Height: 2048}, 8192, 2048}, {"height above the maximum", 600, 3000,
{"height at the maximum", 256, 1024, Size{Width: 2048, Height: 1}, 2048, 8192}, imagerequest.Size{Width: 8192, Height: 1}, 0, 0},
{"within the maximum", 3000, 600, Size{Width: 1, Height: 1200}, 6000, 1200}, {"width at the maximum", 1024, 256,
imagerequest.Size{Width: 1, Height: 2048}, 8192, 2048},
{"height at the maximum", 256, 1024,
imagerequest.Size{Width: 2048, Height: 1}, 2048, 8192},
{"within the maximum", 3000, 600,
imagerequest.Size{Width: 1, Height: 1200}, 6000, 1200},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -36,7 +43,11 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside}) &imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitOutside,
})
if result != nil { if result != nil {
t.Logf("Process() made %dx%d", result.Width, result.Height) t.Logf("Process() made %dx%d", result.Width, result.Height)
} }
@@ -71,11 +82,11 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
inputWidth, inputHeight int inputWidth, inputHeight int
size Size size imagerequest.Size
wantWidth, wantHeight int wantWidth, wantHeight int
}{ }{
{"49x8192 at 0x100", 49, 8192, Size{Height: 100}, 1, 167}, {"49x8192 at 0x100", 49, 8192, imagerequest.Size{Height: 100}, 1, 167},
{"49x49 at 1x1", 49, 49, Size{Width: 1, Height: 1}, 1, 1}, {"49x49 at 1x1", 49, 49, imagerequest.Size{Width: 1, Height: 1}, 1, 1},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -86,7 +97,11 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside}) &imagerequest.Transform{
Size: tt.size,
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitOutside,
})
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
+68 -102
View File
@@ -12,6 +12,7 @@ import (
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// vipsOnce ensures vips is initialized exactly once. // vipsOnce ensures vips is initialized exactly once.
@@ -54,49 +55,6 @@ func CheckJPEGXLSupport() error {
return nil return nil
} }
// Format represents supported output image formats.
type Format string
// Supported image output formats.
const (
FormatOriginal Format = "orig"
FormatJPEG Format = "jpeg"
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif"
)
// FitMode represents how to fit an image into requested dimensions.
type FitMode string
// Supported image fit modes.
const (
FitCover FitMode = "cover"
FitContain FitMode = "contain"
FitFill FitMode = "fill"
FitInside FitMode = "inside"
FitOutside FitMode = "outside"
)
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// Size represents requested image dimensions.
type Size struct {
Width int
Height int
}
// Request holds the parameters for image processing.
type Request struct {
Size Size
Format Format
Quality int
FitMode FitMode
}
// Result contains the output of image processing. // Result contains the output of image processing.
type Result struct { type Result struct {
// Content is the processed image data. // Content is the processed image data.
@@ -133,8 +91,8 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size") var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// MaxOutputDimension is the maximum width or height of an output image, and // MaxOutputDimension is the maximum width or height of an output image, and
// so of a requested size (imgcache.MaxDimension). // so of a requested size (imagerequest.MaxDimension).
const MaxOutputDimension = 8192 const MaxOutputDimension = imagerequest.MaxDimension
// ErrOutputTooLarge is returned, before resizing, when the output would have a // ErrOutputTooLarge is returned, before resizing, when the output would have a
// side above MaxOutputDimension: the side of a size with one side 0, computed // side above MaxOutputDimension: the side of a size with one side 0, computed
@@ -217,7 +175,7 @@ func New(params Params) *ImageProcessor {
} }
} }
// Process transforms an image according to the request. When // Process transforms an image according to transform. When
// MaxConcurrentProcessing images are already being processed, it waits up // MaxConcurrentProcessing images are already being processed, it waits up
// to ProcessingWaitTimeout for one to finish, then fails with // 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
@@ -228,7 +186,7 @@ func New(params Params) *ImageProcessor {
func (p *ImageProcessor) Process( func (p *ImageProcessor) Process(
ctx context.Context, ctx context.Context,
input io.Reader, input io.Reader,
req *Request, transform *imagerequest.Transform,
) (*Result, error) { ) (*Result, error) {
release, err := p.acquireSlot(ctx) release, err := p.acquireSlot(ctx)
if err != nil { if err != nil {
@@ -277,35 +235,36 @@ func (p *ImageProcessor) Process(
} }
// Determine target dimensions; too large ones are refused before resizing // Determine target dimensions; too large ones are refused before resizing
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight) targetWidth, targetHeight, err := targetDimensions(transform.Size,
origWidth, origHeight)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// orig is the source's own format; encode refuses an empty format // orig is the source's own format; encode refuses an empty format
outputFormat := req.Format outputFormat := transform.Format
if outputFormat == FormatOriginal { if outputFormat == imagerequest.FormatOriginal {
outputFormat = sourceFormat outputFormat = sourceFormat
} }
// Determine output dimensions; too large ones are refused before // Determine output dimensions; too large ones are refused before
// resizing, which with FitCover already decodes the input // resizing, which with FitCover already decodes the input
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight, outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
targetWidth, targetHeight, req.FitMode, outputFormat) targetWidth, targetHeight, transform.FitMode, outputFormat)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Resize if needed // Resize if needed
if targetWidth != origWidth || targetHeight != origHeight { if targetWidth != origWidth || targetHeight != origHeight {
err := p.resize(img, outputWidth, outputHeight, req.FitMode) err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to resize: %w", err) return nil, fmt.Errorf("failed to resize: %w", err)
} }
} }
// Encode to target format // Encode to target format
output, err := p.encode(ctx, img, outputFormat, req.Quality) output, err := p.encode(ctx, img, outputFormat, transform.Quality)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encode: %w", err) return nil, fmt.Errorf("failed to encode: %w", err)
} }
@@ -328,7 +287,9 @@ func (p *ImageProcessor) Process(
// rounded down but kept at 1 or more, as for a thin image it would round // rounded down but kept at 1 or more, as for a thin image it would round
// down to 0, a side of no pixels. It can come out above MaxOutputDimension, // down to 0, a side of no pixels. It can come out above MaxOutputDimension,
// which is ErrOutputTooLarge. // which is ErrOutputTooLarge.
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) { func targetDimensions(
size imagerequest.Size, origWidth, origHeight int,
) (int, int, error) {
width, height := size.Width, size.Height width, height := size.Width, size.Height
switch { switch {
@@ -374,33 +335,33 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
} }
// SupportedOutputFormats returns formats this processor can write. // SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []Format { func (p *ImageProcessor) SupportedOutputFormats() []imagerequest.Format {
return []Format{ return []imagerequest.Format{
FormatJPEG, imagerequest.FormatJPEG,
FormatPNG, imagerequest.FormatPNG,
FormatGIF, imagerequest.FormatGIF,
FormatWebP, imagerequest.FormatWebP,
FormatAVIF, imagerequest.FormatAVIF,
FormatJXL, imagerequest.FormatJXL,
} }
} }
// FormatToMIME converts a Format to its MIME type string. // FormatToMIME converts a Format to its MIME type string.
func FormatToMIME(format Format) string { func FormatToMIME(format imagerequest.Format) string {
switch format { switch format {
case FormatJPEG: case imagerequest.FormatJPEG:
return mimeJPEG return mimeJPEG
case FormatPNG: case imagerequest.FormatPNG:
return mimePNG return mimePNG
case FormatWebP: case imagerequest.FormatWebP:
return mimeWebP return mimeWebP
case FormatGIF: case imagerequest.FormatGIF:
return mimeGIF return mimeGIF
case FormatAVIF: case imagerequest.FormatAVIF:
return mimeAVIF return mimeAVIF
case FormatJXL: case imagerequest.FormatJXL:
return mimeJXL return mimeJXL
case FormatOriginal: case imagerequest.FormatOriginal, imagerequest.FormatAuto:
return "application/octet-stream" return "application/octet-stream"
default: default:
return "application/octet-stream" return "application/octet-stream"
@@ -487,9 +448,9 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
case vips.ImageTypeWEBP: case vips.ImageTypeWEBP:
return "webp" return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF: case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return string(FormatAVIF) return string(imagerequest.FormatAVIF)
case vips.ImageTypeJXL: case vips.ImageTypeJXL:
return string(FormatJXL) return string(imagerequest.FormatJXL)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF, case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP, vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K: vips.ImageTypeJP2K:
@@ -506,17 +467,18 @@ func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
// outputDimensions returns ErrOutputTooLarge. An AVIF output of more than // outputDimensions returns ErrOutputTooLarge. An AVIF output of more than
// MaxAVIFOutputPixels is ErrAVIFOutputTooLarge. // MaxAVIFOutputPixels is ErrAVIFOutputTooLarge.
func outputDimensions( func outputDimensions(
imgWidth, imgHeight, width, height int, fit FitMode, format Format, imgWidth, imgHeight, width, height int,
fit imagerequest.Fit, format imagerequest.Format,
) (int, int, error) { ) (int, int, error) {
scaleW := float64(width) / float64(imgWidth) scaleW := float64(width) / float64(imgWidth)
scaleH := float64(height) / float64(imgHeight) scaleH := float64(height) / float64(imgHeight)
outputWidth, outputHeight := width, height outputWidth, outputHeight := width, height
switch fit { switch fit {
case FitCover, FitFill, "": case imagerequest.FitCover, imagerequest.FitFill, "":
// Cover crops, and fill stretches, to exactly the target dimensions // Cover crops, and fill stretches, to exactly the target dimensions
case FitContain: case imagerequest.FitContain:
// Fit within the target dimensions, maintaining aspect ratio. Scaled // Fit within the target dimensions, maintaining aspect ratio. Scaled
// by the smaller scale, the short side of a thin image can round // by the smaller scale, the short side of a thin image can round
// down to 0, which libvips refuses as a width, so each side is at // down to 0, which libvips refuses as a width, so each side is at
@@ -525,13 +487,13 @@ func outputDimensions(
outputWidth = max(1, int(float64(imgWidth)*scale)) outputWidth = max(1, int(float64(imgWidth)*scale))
outputHeight = max(1, int(float64(imgHeight)*scale)) outputHeight = max(1, int(float64(imgHeight)*scale))
case FitInside: case imagerequest.FitInside:
// Same as contain, but only shrink // Same as contain, but only shrink
scale := min(scaleW, scaleH, 1) scale := min(scaleW, scaleH, 1)
outputWidth = max(1, int(float64(imgWidth)*scale)) outputWidth = max(1, int(float64(imgWidth)*scale))
outputHeight = max(1, int(float64(imgHeight)*scale)) outputHeight = max(1, int(float64(imgHeight)*scale))
case FitOutside: case imagerequest.FitOutside:
// The smallest dimension fits, the other may exceed the target. A // The smallest dimension fits, the other may exceed the target. A
// side scaled to 1 can come out just under 1 in floating point, as // side scaled to 1 can come out just under 1 in floating point, as
// 49 times 1/49 does, and round down to 0, which libvips refuses as // 49 times 1/49 does, and round down to 0, which libvips refuses as
@@ -541,7 +503,7 @@ func outputDimensions(
outputHeight = max(1, int(float64(imgHeight)*scale)) outputHeight = max(1, int(float64(imgHeight)*scale))
default: default:
return 0, 0, fmt.Errorf("%w: %s", ErrInvalidFitMode, fit) return 0, 0, fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
} }
// The side FitOutside scales past the target is held to the maximum too // The side FitOutside scales past the target is held to the maximum too
@@ -550,7 +512,8 @@ func outputDimensions(
outputWidth, outputHeight, MaxOutputDimension) outputWidth, outputHeight, MaxOutputDimension)
} }
if format == FormatAVIF && outputWidth*outputHeight > MaxAVIFOutputPixels { if format == imagerequest.FormatAVIF &&
outputWidth*outputHeight > MaxAVIFOutputPixels {
return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels", return 0, 0, fmt.Errorf("%w: %dx%d is above %d pixels",
ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels) ErrAVIFOutputTooLarge, outputWidth, outputHeight, MaxAVIFOutputPixels)
} }
@@ -561,18 +524,18 @@ func outputDimensions(
// resize resizes the image to width x height, the dimensions // resize resizes the image to width x height, the dimensions
// outputDimensions gives for the fit mode. // outputDimensions gives for the fit mode.
func (p *ImageProcessor) resize( func (p *ImageProcessor) resize(
img *vips.ImageRef, width, height int, fit FitMode, img *vips.ImageRef, width, height int, fit imagerequest.Fit,
) error { ) error {
switch fit { switch fit {
case FitCover, "": case imagerequest.FitCover, "":
// Crop the centre and resize to fill exact dimensions (default) // Crop the centre and resize to fill exact dimensions (default)
return cropCentreAndResize(img, width, height) return cropCentreAndResize(img, width, height)
case FitFill: case imagerequest.FitFill:
// Resize to exact dimensions (may distort) // Resize to exact dimensions (may distort)
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case FitInside: case imagerequest.FitInside:
// outputDimensions keeps the dimensions of an image that already fits // outputDimensions keeps the dimensions of an image that already fits
if img.Width() == width && img.Height() == height { if img.Width() == width && img.Height() == height {
return nil return nil
@@ -580,11 +543,11 @@ func (p *ImageProcessor) resize(
return img.Thumbnail(width, height, vips.InterestingNone) return img.Thumbnail(width, height, vips.InterestingNone)
case FitContain, FitOutside: case imagerequest.FitContain, imagerequest.FitOutside:
return img.Thumbnail(width, height, vips.InterestingNone) return img.Thumbnail(width, height, vips.InterestingNone)
default: default:
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit) return fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, fit)
} }
} }
@@ -626,7 +589,8 @@ const defaultQuality = 85
// to stop libvips once an encode has begun, so when ctx has ended encode // to stop libvips once an encode has begun, so when ctx has ended encode
// returns ctx's error without beginning one. // returns ctx's error without beginning one.
func (p *ImageProcessor) encode( func (p *ImageProcessor) encode(
ctx context.Context, img *vips.ImageRef, format Format, quality int, ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
quality int,
) ([]byte, error) { ) ([]byte, error) {
err := ctx.Err() err := ctx.Err()
if err != nil { if err != nil {
@@ -649,25 +613,25 @@ func (p *ImageProcessor) encode(
} }
switch format { switch format {
case FormatJPEG: case imagerequest.FormatJPEG:
return exportJPEG(img, quality) return exportJPEG(img, quality)
case FormatPNG: case imagerequest.FormatPNG:
return exportPNG(img) return exportPNG(img)
case FormatGIF: case imagerequest.FormatGIF:
return exportGIF(img) return exportGIF(img)
case FormatWebP: case imagerequest.FormatWebP:
return exportWebP(img, quality) return exportWebP(img, quality)
case FormatAVIF: case imagerequest.FormatAVIF:
return exportAVIF(img, quality) return exportAVIF(img, quality)
case FormatJXL: case imagerequest.FormatJXL:
return exportJXL(img, quality) return exportJXL(img, quality)
case FormatOriginal: case imagerequest.FormatOriginal, imagerequest.FormatAuto:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
default: default:
@@ -849,20 +813,22 @@ func jxlDistance(quality int) float64 {
// formatFromString converts a format string from detectFormat to Format. A // formatFromString converts a format string from detectFormat to Format. A
// format detectFormat could not name is ErrUnsupportedInputFormat. // format detectFormat could not name is ErrUnsupportedInputFormat.
func (p *ImageProcessor) formatFromString(format string) (Format, error) { func (p *ImageProcessor) formatFromString(
format string,
) (imagerequest.Format, error) {
switch format { switch format {
case "jpeg": case "jpeg":
return FormatJPEG, nil return imagerequest.FormatJPEG, nil
case "png": case "png":
return FormatPNG, nil return imagerequest.FormatPNG, nil
case "gif": case "gif":
return FormatGIF, nil return imagerequest.FormatGIF, nil
case "webp": case "webp":
return FormatWebP, nil return imagerequest.FormatWebP, nil
case string(FormatAVIF): case string(imagerequest.FormatAVIF):
return FormatAVIF, nil return imagerequest.FormatAVIF, nil
case string(FormatJXL): case string(imagerequest.FormatJXL):
return FormatJXL, nil return imagerequest.FormatJXL, nil
default: default:
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format) return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
} }
@@ -15,6 +15,7 @@ import (
"testing" "testing"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -87,7 +88,7 @@ func isAVIF(data []byte) bool {
brand := string(data[8:12]) brand := string(data[8:12])
return brand == string(FormatAVIF) || brand == "avis" return brand == string(imagerequest.FormatAVIF) || brand == "avis"
} }
// detectMIME is a minimal magic-byte detector for test assertions. // detectMIME is a minimal magic-byte detector for test assertions.
@@ -123,14 +124,14 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 400, Height: 300}, Size: imagerequest.Size{Width: 400, Height: 300},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -169,13 +170,13 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 200, Height: 150}, Size: imagerequest.Size{Width: 200, Height: 150},
Format: FormatPNG, Format: imagerequest.FormatPNG,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -196,7 +197,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
// processAndCheckSize processes a test JPEG of the given input dimensions // processAndCheckSize processes a test JPEG of the given input dimensions
// with the requested size and asserts the resulting dimensions. // with the requested size and asserts the resulting dimensions.
func processAndCheckSize( func processAndCheckSize(
t *testing.T, inputW, inputH int, size Size, wantW, wantH int, t *testing.T, inputW, inputH int, size imagerequest.Size, wantW, wantH int,
) { ) {
t.Helper() t.Helper()
@@ -205,14 +206,14 @@ func processAndCheckSize(
input := createTestJPEG(t, inputW, inputH) input := createTestJPEG(t, inputW, inputH)
req := &Request{ transform := &imagerequest.Transform{
Size: size, Size: size,
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -232,7 +233,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
t.Parallel() t.Parallel()
// Width and height 0: keep original size // Width and height 0: keep original size
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480) processAndCheckSize(t, 640, 480, imagerequest.Size{Width: 0, Height: 0}, 640, 480)
} }
func TestImageProcessor_FitContain(t *testing.T) { func TestImageProcessor_FitContain(t *testing.T) {
@@ -245,14 +246,14 @@ func TestImageProcessor_FitContain(t *testing.T) {
// Should result in 400x200 (maintaining aspect ratio) // Should result in 400x200 (maintaining aspect ratio)
input := createTestJPEG(t, 800, 400) input := createTestJPEG(t, 800, 400)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 400, Height: 400}, Size: imagerequest.Size{Width: 400, Height: 400},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitContain, FitMode: imagerequest.FitContain,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -270,7 +271,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
// 800x600 image, request width=400 height=0 // 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300) processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 400, Height: 0}, 400, 300)
} }
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
@@ -278,7 +279,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
// 800x600 image, request width=0 height=300 // 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300) processAndCheckSize(t, 800, 600, imagerequest.Size{Width: 0, Height: 300}, 400, 300)
} }
func TestImageProcessor_ProcessPNG(t *testing.T) { func TestImageProcessor_ProcessPNG(t *testing.T) {
@@ -289,13 +290,13 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
input := createTestPNG(t, 400, 300) input := createTestPNG(t, 400, 300)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 200, Height: 150}, Size: imagerequest.Size{Width: 200, Height: 150},
Format: FormatPNG, Format: imagerequest.FormatPNG,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
@@ -349,14 +350,14 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, tt.width, tt.height) input := createTestJPEG(t, tt.width, tt.height)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 100, Height: 100}, Size: imagerequest.Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
_, err := proc.Process(ctx, bytes.NewReader(input), req) _, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err == nil { if err == nil {
t.Error("Process() should reject oversized input images") t.Error("Process() should reject oversized input images")
} }
@@ -377,14 +378,14 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
// Create an image at exactly MaxInputDimension - should be accepted // Create an image at exactly MaxInputDimension - should be accepted
input := createTestJPEG(t, MaxInputDimension, 100) input := createTestJPEG(t, MaxInputDimension, 100)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 100, Height: 100}, Size: imagerequest.Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"Process() should accept images at MaxInputDimension, got error: %v", "Process() should accept images at MaxInputDimension, got error: %v",
@@ -397,7 +398,9 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the // encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
// given format and asserts the output MIME type and dimensions. // given format and asserts the output MIME type and dimensions.
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) { func encodeAndCheck(
t *testing.T, format imagerequest.Format, quality int, wantMIME string,
) {
t.Helper() t.Helper()
proc := New(Params{}) proc := New(Params{})
@@ -405,14 +408,14 @@ func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 100, Height: 75}, Size: imagerequest.Size{Width: 100, Height: 75},
Format: format, Format: format,
Quality: quality, Quality: quality,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v, want nil", err) t.Fatalf("Process() error = %v, want nil", err)
} }
@@ -443,7 +446,7 @@ func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
func TestImageProcessor_EncodeWebP(t *testing.T) { func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Parallel() t.Parallel()
encodeAndCheck(t, FormatWebP, 80, mimeWebP) encodeAndCheck(t, imagerequest.FormatWebP, 80, mimeWebP)
} }
func TestImageProcessor_DecodeAVIF(t *testing.T) { func TestImageProcessor_DecodeAVIF(t *testing.T) {
@@ -459,14 +462,14 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
} }
// Request resize and convert to JPEG // Request resize and convert to JPEG
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 2, Height: 2}, Size: imagerequest.Size{Width: 2, Height: 2},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), 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)
} }
@@ -500,14 +503,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input)) t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
} }
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 100, Height: 75}, Size: imagerequest.Size{Width: 100, Height: 75},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
_, err := proc.Process(ctx, bytes.NewReader(input), req) _, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err == nil { if err == nil {
t.Fatal("Process() should reject input exceeding maxInputBytes") t.Fatal("Process() should reject input exceeding maxInputBytes")
} }
@@ -527,14 +530,14 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
proc := New(Params{MaxInputBytes: limit}) proc := New(Params{MaxInputBytes: limit})
ctx := context.Background() ctx := context.Background()
req := &Request{ transform := &imagerequest.Transform{
Size: Size{Width: 10, Height: 10}, Size: imagerequest.Size{Width: 10, Height: 10},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), transform)
if err != nil { if err != nil {
t.Fatalf("Process() error = %v, want nil", err) t.Fatalf("Process() error = %v, want nil", err)
} }
@@ -561,16 +564,18 @@ func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
func TestImageProcessor_EncodeAVIF(t *testing.T) { func TestImageProcessor_EncodeAVIF(t *testing.T) {
t.Parallel() t.Parallel()
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF) encodeAndCheck(t, imagerequest.FormatAVIF, 85, mimeAVIF)
} }
// processAndDecode runs input through Process and decodes the output with // processAndDecode runs input through Process and decodes the output 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(t *testing.T, input []byte, req *Request) *vips.ImageRef { func processAndDecode(
t *testing.T, input []byte, transform *imagerequest.Transform,
) *vips.ImageRef {
t.Helper() t.Helper()
result, err := New(Params{}).Process( result, err := New(Params{}).Process(
context.Background(), bytes.NewReader(input), req, context.Background(), bytes.NewReader(input), transform,
) )
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
@@ -614,15 +619,17 @@ func TestImageProcessor_StripsEXIF(t *testing.T) {
t.Fatal("testdata/gps-exif.jpg has no GPS latitude") t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
} }
formats := []Format{ formats := []imagerequest.Format{
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal, imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatWebP,
imagerequest.FormatAVIF, imagerequest.FormatGIF, imagerequest.FormatOriginal,
} }
for _, format := range formats { for _, format := range formats {
t.Run(string(format), func(t *testing.T) { t.Run(string(format), func(t *testing.T) {
t.Parallel() t.Parallel()
output := processAndDecode(t, input, &Request{Format: format}) output := processAndDecode(t, input,
&imagerequest.Transform{Format: format})
if output.HasExif() { if output.HasExif() {
t.Errorf("output has EXIF: %v", output.GetExif()) t.Errorf("output has EXIF: %v", output.GetExif())
@@ -644,21 +651,21 @@ func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
size Size size imagerequest.Size
wantW int wantW int
wantH int wantH int
}{ }{
{name: "original size", size: Size{}, wantW: 8, wantH: 16}, {name: "original size", size: imagerequest.Size{}, wantW: 8, wantH: 16},
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8}, {name: "width only", size: imagerequest.Size{Width: 4}, wantW: 4, wantH: 8},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
output := processAndDecode(t, input, &Request{ output := processAndDecode(t, input, &imagerequest.Transform{
Size: tt.size, Size: tt.size,
Format: FormatPNG, Format: imagerequest.FormatPNG,
}) })
if output.Width() != tt.wantW || output.Height() != tt.wantH { if output.Width() != tt.wantW || output.Height() != tt.wantH {
@@ -694,7 +701,8 @@ func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err) t.Fatalf("failed to read test JPEG: %v", err)
} }
output := processAndDecode(t, input, &Request{Format: FormatPNG}) output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatPNG})
if output.HasICCProfile() { if output.HasICCProfile() {
t.Error("output has an ICC profile") t.Error("output has an ICC profile")
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and // TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
@@ -15,10 +16,13 @@ import (
func TestImageProcessor_EncodeJPEGXL(t *testing.T) { func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
t.Parallel() t.Parallel()
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL} transform := &imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 75},
Format: imagerequest.FormatJXL,
}
result, err := New(Params{}).Process( result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req, t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
) )
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
@@ -61,7 +65,8 @@ func TestImageProcessor_JPEGXLQuality(t *testing.T) {
for _, quality := range []int{30, 90} { for _, quality := range []int{30, 90} {
result, err := New(Params{}).Process(t.Context(), result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality}) bytes.NewReader(input),
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
if err != nil { if err != nil {
t.Fatalf("Process() at quality %d error = %v", quality, err) t.Fatalf("Process() at quality %d error = %v", quality, err)
} }
@@ -89,7 +94,8 @@ func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err) t.Fatalf("failed to read test JPEG: %v", err)
} }
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif() exif := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL}).GetExif()
for _, field := range []string{ for _, field := range []string{
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber", "exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
@@ -115,7 +121,8 @@ func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err) t.Fatalf("failed to read test JPEG: %v", err)
} }
output := processAndDecode(t, input, &Request{Format: FormatJXL}) output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
// libvips gives the resolution in pixels per millimetre. // libvips gives the resolution in pixels per millimetre.
xDPI := math.Round(output.ResX() * 25.4) xDPI := math.Round(output.ResX() * 25.4)
@@ -140,7 +147,8 @@ func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err) t.Fatalf("failed to read test JPEG: %v", err)
} }
output := processAndDecode(t, input, &Request{Format: FormatJXL}) output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
if output.Width() != 8 || output.Height() != 16 { if output.Width() != 8 || output.Height() != 16 {
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height()) t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
@@ -176,7 +184,8 @@ func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err) t.Fatalf("failed to read test JPEG: %v", err)
} }
output := processAndDecode(t, input, &Request{Format: FormatJXL}) output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
pixel, err := output.GetPoint(4, 4) pixel, err := output.GetPoint(4, 4)
if err != nil { if err != nil {
@@ -203,7 +212,8 @@ func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
t.Fatalf("failed to read test JPEG: %v", err) t.Fatalf("failed to read test JPEG: %v", err)
} }
output := processAndDecode(t, input, &Request{Format: FormatJXL}) output := processAndDecode(t, input,
&imagerequest.Transform{Format: imagerequest.FormatJXL})
if output.Bands() != 3 { if output.Bands() != 3 {
t.Fatalf("output has %d bands, want 3", output.Bands()) t.Fatalf("output has %d bands, want 3", output.Bands())
@@ -11,6 +11,8 @@ import (
"testing" "testing"
"testing/iotest" "testing/iotest"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// errTestReadFailed is the error the unreadable test input returns. // errTestReadFailed is the error the unreadable test input returns.
@@ -76,13 +78,13 @@ func (r *gatedReader) Read(p []byte) (int, error) {
return n, err return n, err
} }
// smallJPEGRequest asks for a 5x5 JPEG. // smallJPEGTransform asks for a 5x5 JPEG.
func smallJPEGRequest() *Request { func smallJPEGTransform() *imagerequest.Transform {
return &Request{ return &imagerequest.Transform{
Size: Size{Width: 5, Height: 5}, Size: imagerequest.Size{Width: 5, Height: 5},
Format: FormatJPEG, Format: imagerequest.FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: imagerequest.FitCover,
} }
} }
@@ -92,7 +94,7 @@ func processInBackground(
proc *ImageProcessor, reader *gatedReader, results chan<- error, proc *ImageProcessor, reader *gatedReader, results chan<- error,
) { ) {
go func() { go func() {
result, err := proc.Process(context.Background(), reader, smallJPEGRequest()) result, err := proc.Process(context.Background(), reader, smallJPEGTransform())
if err == nil { if err == nil {
_ = result.Content.Close() _ = result.Content.Close()
} }
@@ -237,7 +239,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
start := time.Now() start := time.Now()
_, err := proc.Process(context.Background(), bytes.NewReader(input), _, err := proc.Process(context.Background(), bytes.NewReader(input),
smallJPEGRequest()) smallJPEGTransform())
if !errors.Is(err, ErrTooManyImages) { if !errors.Is(err, ErrTooManyImages) {
t.Fatalf("Process() error = %v, want ErrTooManyImages", err) t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
} }
@@ -263,38 +265,38 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
valid := createTestJPEG(t, 10, 10) valid := createTestJPEG(t, 10, 10)
unsupported := smallJPEGRequest() unsupported := smallJPEGTransform()
unsupported.Format = "bmp" unsupported.Format = "bmp"
cases := []struct { cases := []struct {
name string name string
input io.Reader input io.Reader
req *Request transform *imagerequest.Transform
// want is the error Process must return; nil means any error. // want is the error Process must return; nil means any error.
want error want error
}{ }{
{ {
name: "input cannot be read", name: "input cannot be read",
input: iotest.ErrReader(errTestReadFailed), input: iotest.ErrReader(errTestReadFailed),
req: smallJPEGRequest(), transform: smallJPEGTransform(),
want: errTestReadFailed, want: errTestReadFailed,
}, },
{ {
name: "input over the byte limit", name: "input over the byte limit",
input: bytes.NewReader(createTestJPEG(t, 800, 600)), input: bytes.NewReader(createTestJPEG(t, 800, 600)),
req: smallJPEGRequest(), transform: smallJPEGTransform(),
want: ErrInputDataTooLarge, want: ErrInputDataTooLarge,
}, },
{ {
name: "input not an image", name: "input not an image",
input: strings.NewReader("not an image"), input: strings.NewReader("not an image"),
req: smallJPEGRequest(), transform: smallJPEGTransform(),
}, },
{ {
name: "output format not supported", name: "output format not supported",
input: bytes.NewReader(valid), input: bytes.NewReader(valid),
req: unsupported, transform: unsupported,
want: ErrUnsupportedOutputFormat, want: ErrUnsupportedOutputFormat,
}, },
} }
@@ -305,7 +307,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1}) proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
proc.processingWaitTimeout = 100 * time.Millisecond proc.processingWaitTimeout = 100 * time.Millisecond
_, err := proc.Process(context.Background(), tc.input, tc.req) _, err := proc.Process(context.Background(), tc.input, tc.transform)
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) { if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
t.Fatalf("Process() error = %v, want %v", err, tc.want) t.Fatalf("Process() error = %v, want %v", err, tc.want)
} }
@@ -315,7 +317,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
} }
result, err := proc.Process(context.Background(), bytes.NewReader(valid), result, err := proc.Process(context.Background(), bytes.NewReader(valid),
smallJPEGRequest()) 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)
} }
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF // TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
@@ -27,7 +28,8 @@ func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
} }
result, err := New(Params{}).Process( result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(tiff), &Request{Format: FormatOriginal}, t.Context(), bytes.NewReader(tiff),
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
) )
if result != nil { if result != nil {
t.Logf("Process() returned %s", result.ContentType) t.Logf("Process() returned %s", result.ContentType)
@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"io" "io"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of // TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
@@ -14,11 +16,13 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
proc := New(Params{}) proc := New(Params{})
for _, format := range []Format{ for _, format := range []imagerequest.Format{
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL, imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
} { } {
saved, err := proc.Process(t.Context(), saved, err := proc.Process(t.Context(),
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format}) bytes.NewReader(createTestPNG(t, 20, 20)),
&imagerequest.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)
} }
@@ -29,7 +33,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
} }
result, err := proc.Process(t.Context(), bytes.NewReader(source), result, err := proc.Process(t.Context(), bytes.NewReader(source),
&Request{Format: FormatOriginal}) &imagerequest.Transform{Format: imagerequest.FormatOriginal})
if err != nil { if err != nil {
t.Errorf("orig of the %s image: %v", format, err) t.Errorf("orig of the %s image: %v", format, err)
+147
View File
@@ -0,0 +1,147 @@
// Package imagerequest holds the image request type, its parts, its checks,
// the parsing of the /v1/image/ path and the variant key. It imports no other
// package of this repo, so every package that handles an image request can
// import it.
package imagerequest
import (
"errors"
"net/url"
"time"
)
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// Format represents supported output image formats.
type Format string
// Supported image output formats.
const (
FormatOriginal Format = "orig"
FormatJPEG Format = "jpeg"
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif"
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
// request from its Accept header once the URL's signature or token has
// been checked; it is never processed or cached as itself.
FormatAuto Format = "auto"
)
// Size represents requested image dimensions
type Size struct {
Width int
Height int
}
// OriginalSize returns true if this represents "keep original size"
func (s Size) OriginalSize() bool {
return s.Width == 0 && s.Height == 0
}
// Fit represents how to fit image into requested dimensions.
type Fit string
// Supported image fit modes.
const (
FitCover Fit = "cover"
FitContain Fit = "contain"
FitFill Fit = "fill"
FitInside Fit = "inside"
FitOutside Fit = "outside"
)
// ValidateFitMode checks if the given fit mode is valid.
// Returns ErrInvalidFitMode for unrecognized fit modes.
func ValidateFitMode(fit Fit) error {
switch fit {
case FitCover, FitContain, FitFill, FitInside, FitOutside, "":
return nil
default:
return ErrInvalidFitMode
}
}
// ValidateImageRequest checks a request's width and height with
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
// /v1/image/ route and the encrypted /v1/e/ route validate through this
// function so a request from either source enforces identical bounds.
func ValidateImageRequest(req *Request) error {
err := ValidateDimension("width", req.Transform.Size.Width)
if err != nil {
return err
}
err = ValidateDimension("height", req.Transform.Size.Height)
if err != nil {
return err
}
return ValidateFitMode(req.Transform.FitMode)
}
// Request represents a request for a processed image: the source to fetch
// and the transform to apply to it.
type Request struct {
// Source is what to fetch
Source Source
// Transform is what to do to it
Transform Transform
// Signature is the HMAC signature for non-allowlisted hosts
Signature string
// Expires is when the URL expires: the exp of a signed URL, or the expiry
// of an encrypted URL; the zero time if it has none
Expires time.Time
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
AllowHTTP bool
}
// Source is the image a request fetches from the origin.
type Source struct {
// Host is the origin host (e.g., "cdn.example.com")
Host string
// Path is the path on the origin with its percent-escapes decoded, as
// url.URL's Path holds it (e.g., "/photos/my cat.jpg")
Path string
// Query is the optional query string for the origin URL as the URL
// writes it, percent-escapes included, as url.URL's RawQuery holds it
Query string
}
// Transform is what a request does to its source; the image processor
// takes it alone.
type Transform struct {
// Size is the requested output dimensions
Size Size
// Format is the requested output format
Format Format
// Quality is the output quality (1-100) for lossy formats
Quality int
// FitMode is how to fit the image into requested dimensions
FitMode Fit
}
// SourceURL returns the full upstream URL to fetch: the source path
// percent-encoded again, as net/url writes a path, so each source path is one
// upstream path, and the source query as it is, with the characters
// escapeQuery encodes percent-encoded. Uses http:// scheme when AllowHTTP is
// true, otherwise https://.
func (r *Request) SourceURL() string {
scheme := "https"
if r.AllowHTTP {
scheme = "http"
}
sourceURL := url.URL{
Scheme: scheme,
Host: r.Source.Host,
Path: r.Source.Path,
RawQuery: escapeQuery(r.Source.Query),
}
return sourceURL.String()
}
@@ -1,14 +1,16 @@
package imgcache package imagerequest
import "testing" import "testing"
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) { func TestRequest_SourceURL_DefaultHTTPS(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &Request{
SourceHost: testHostCDN, Source: Source{
SourcePath: testPathCat, Host: testHostCDN,
SourceQuery: "v=2", Path: testPathCat,
Query: "v=2",
},
} }
got := req.SourceURL() got := req.SourceURL()
@@ -19,13 +21,15 @@ func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
} }
} }
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) { func TestRequest_SourceURL_AllowHTTP(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &Request{
SourceHost: "localhost:8080", Source: Source{
SourcePath: testPathCat, Host: "localhost:8080",
AllowHTTP: true, Path: testPathCat,
},
AllowHTTP: true,
} }
got := req.SourceURL() got := req.SourceURL()
@@ -36,13 +40,15 @@ func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
} }
} }
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) { func TestRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &Request{
SourceHost: testHostCDN, Source: Source{
SourcePath: "/img.jpg", Host: testHostCDN,
AllowHTTP: false, Path: "/img.jpg",
},
AllowHTTP: false,
} }
got := req.SourceURL() got := req.SourceURL()
@@ -1,4 +1,4 @@
package imgcache package imagerequest
import ( import (
"errors" "errors"
@@ -8,8 +8,6 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sneak.berlin/go/pixa/internal/imageprocessor"
) )
// URL parsing errors. // URL parsing errors.
@@ -25,8 +23,8 @@ var (
) )
// MaxDimension is the maximum allowed width or height, the same as the image // MaxDimension is the maximum allowed width or height, the same as the image
// processor's maximum for an output image. // processor's maximum for an output image (imageprocessor.MaxOutputDimension).
const MaxDimension = imageprocessor.MaxOutputDimension const MaxDimension = 8192
// ValidateDimension checks one requested width or height; name ("width" or // ValidateDimension checks one requested width or height; name ("width" or
// "height") appears in the error. 0 is valid: that side is then worked out from // "height") appears in the error. 0 is valid: that side is then worked out from
@@ -68,20 +66,6 @@ var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
var hostRegex = regexp.MustCompile( var hostRegex = regexp.MustCompile(
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`) `^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
// ParsedURL contains the parsed components of an image proxy URL.
type ParsedURL struct {
// Host is the source origin host (e.g., "cdn.example.com")
Host string
// Path is the path on the origin (e.g., "/photos/cat.jpg")
Path string
// Query is the optional query string for the origin
Query string
// Size is the requested output dimensions
Size Size
// Format is the requested output format
Format ImageFormat
}
// ParseImagePath parses an image URL's path after /v1/image/, decoded once: // ParseImagePath parses an image URL's path after /v1/image/, decoded once:
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL. // <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
// Examples: // Examples:
@@ -89,7 +73,7 @@ type ParsedURL struct {
// - cdn.example.com/photos/cat.jpg/0x0.jpeg // - cdn.example.com/photos/cat.jpg/0x0.jpeg
// - cdn.example.com/photos/cat.jpg/orig.png // - cdn.example.com/photos/cat.jpg/orig.png
// - cdn.example.com/photos/cat.jpg/800x600 // - cdn.example.com/photos/cat.jpg/800x600
func ParseImagePath(path string) (*ParsedURL, error) { func ParseImagePath(path string) (*Request, error) {
// Strip leading slash if present (chi may include it) // Strip leading slash if present (chi may include it)
path = strings.TrimPrefix(path, "/") path = strings.TrimPrefix(path, "/")
if path == "" { if path == "" {
@@ -103,7 +87,7 @@ func ParseImagePath(path string) (*ParsedURL, error) {
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size> // it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
// for JPEG XL. A source query follows the source path after a "?", which the // for JPEG XL. A source query follows the source path after a "?", which the
// URL writes as %3F. // URL writes as %3F.
func ParseImageURL(urlPath string) (*ParsedURL, error) { func ParseImageURL(urlPath string) (*Request, error) {
// Remove the /v1/image/ prefix // Remove the /v1/image/ prefix
const prefix = "/v1/image/" const prefix = "/v1/image/"
if !strings.HasPrefix(urlPath, prefix) { if !strings.HasPrefix(urlPath, prefix) {
@@ -120,7 +104,7 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
// parseImageComponents parses <host>/<path>/<size>.<format>, or // parseImageComponents parses <host>/<path>/<size>.<format>, or
// <host>/<path>/<size> for JPEG XL. // <host>/<path>/<size> for JPEG XL.
func parseImageComponents(remainder string) (*ParsedURL, error) { func parseImageComponents(remainder string) (*Request, error) {
// Find the last path segment, which holds "size" or "size.format" // Find the last path segment, which holds "size" or "size.format"
lastSlash := strings.LastIndex(remainder, "/") lastSlash := strings.LastIndex(remainder, "/")
if lastSlash == -1 { if lastSlash == -1 {
@@ -209,12 +193,9 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
path = "/" + path path = "/" + path
} }
return &ParsedURL{ return &Request{
Host: host, Source: Source{Host: host, Path: path, Query: query},
Path: path, Transform: Transform{Size: size, Format: format},
Query: query,
Size: size,
Format: format,
}, nil }, nil
} }
@@ -297,7 +278,7 @@ func escapeQuery(query string) string {
} }
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600" // parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
func parseSizeFormat(s string) (Size, ImageFormat, error) { func parseSizeFormat(s string) (Size, Format, error) {
matches := sizeFormatRegex.FindStringSubmatch(s) matches := sizeFormatRegex.FindStringSubmatch(s)
if matches == nil { if matches == nil {
return Size{}, "", ErrInvalidSize return Size{}, "", ErrInvalidSize
@@ -351,8 +332,8 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
return size, format, nil return size, format, nil
} }
// parseFormat converts a format string to ImageFormat. // parseFormat converts a format string to Format.
func parseFormat(s string) (ImageFormat, error) { func parseFormat(s string) (Format, error) {
switch strings.ToLower(s) { switch strings.ToLower(s) {
case "orig", "original": case "orig", "original":
return FormatOriginal, nil return FormatOriginal, nil
@@ -374,14 +355,3 @@ func parseFormat(s string) (ImageFormat, error) {
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s) return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
} }
} }
// ToImageRequest converts a ParsedURL to an ImageRequest.
func (p *ParsedURL) ToImageRequest() *ImageRequest {
return &ImageRequest{
SourceHost: p.Host,
SourcePath: p.Path,
SourceQuery: p.Query,
Size: p.Size,
Format: p.Format,
}
}
@@ -1,33 +1,41 @@
package imgcache package imagerequest
import ( import (
"errors" "errors"
"testing" "testing"
) )
// assertParsedURL compares all fields of a parsed URL against the // Shared test fixture strings.
// expected value. const (
func assertParsedURL(t *testing.T, got, want *ParsedURL) { testHostCDN = "cdn.example.com"
testHostExample = "example.com"
testPathCat = "/photos/cat.jpg"
)
// assertRequest compares the source host, path and query and the size and
// format of got against want.
func assertRequest(t *testing.T, got, want *Request) {
t.Helper() t.Helper()
if got.Host != want.Host { if got.Source.Host != want.Source.Host {
t.Errorf("Host = %q, want %q", got.Host, want.Host) t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
} }
if got.Path != want.Path { if got.Source.Path != want.Source.Path {
t.Errorf("Path = %q, want %q", got.Path, want.Path) t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
} }
if got.Query != want.Query { if got.Source.Query != want.Source.Query {
t.Errorf("Query = %q, want %q", got.Query, want.Query) t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
} }
if got.Size != want.Size { if got.Transform.Size != want.Transform.Size {
t.Errorf("Size = %v, want %v", got.Size, want.Size) t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
} }
if got.Format != want.Format { if got.Transform.Format != want.Transform.Format {
t.Errorf("Format = %q, want %q", got.Format, want.Format) t.Errorf("Format = %q, want %q",
got.Transform.Format, want.Transform.Format)
} }
} }
@@ -37,62 +45,64 @@ func TestParseImageURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
want *ParsedURL want *Request
}{ }{
{ {
name: "basic path with size", name: "basic path with size",
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp", input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: testPathCat, Source: Source{Host: testHostCDN, Path: testPathCat},
Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
}, },
}, },
{ {
name: "original size with 0x0", name: "original size with 0x0",
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg", input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: testPathCat, Source: Source{Host: testHostCDN, Path: testPathCat},
Size: Size{Width: 0, Height: 0}, Format: FormatJPEG, Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
}, },
}, },
{ {
name: "original size with orig keyword", name: "original size with orig keyword",
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png", input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: testPathCat, Source: Source{Host: testHostCDN, Path: testPathCat},
Size: Size{Width: 0, Height: 0}, Format: FormatPNG, Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
}, },
}, },
{ {
name: "path with query string", name: "path with query string",
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp", input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2", Source: Source{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
},
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
}, },
}, },
{ {
name: "deep nested path", name: "deep nested path",
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif", input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: "/a/b/c/d/image.jpg", Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF, Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
}, },
}, },
{ {
name: "jpg alias for jpeg", name: "jpg alias for jpeg",
input: "/v1/image/example.com/img.png/100x100.jpg", input: "/v1/image/example.com/img.png/100x100.jpg",
want: &ParsedURL{ want: &Request{
Host: testHostExample, Path: "/img.png", Source: Source{Host: testHostExample, Path: "/img.png"},
Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
}, },
}, },
{ {
name: "gif format", name: "gif format",
input: "/v1/image/example.com/animated.gif/200x200.gif", input: "/v1/image/example.com/animated.gif/200x200.gif",
want: &ParsedURL{ want: &Request{
Host: testHostExample, Path: "/animated.gif", Source: Source{Host: testHostExample, Path: "/animated.gif"},
Size: Size{Width: 200, Height: 200}, Format: FormatGIF, Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
}, },
}, },
} }
@@ -106,7 +116,7 @@ func TestParseImageURL(t *testing.T) {
t.Fatalf("ParseImageURL() unexpected error = %v", err) t.Fatalf("ParseImageURL() unexpected error = %v", err)
} }
assertParsedURL(t, got, tt.want) assertRequest(t, got, tt.want)
}) })
} }
} }
@@ -121,8 +131,8 @@ func TestParseImageURL_AutoFormat(t *testing.T) {
t.Fatalf("ParseImageURL() error = %v", err) t.Fatalf("ParseImageURL() error = %v", err)
} }
if got.Format != FormatAuto { if got.Transform.Format != FormatAuto {
t.Errorf("Format = %q, want %q", got.Format, FormatAuto) t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
} }
} }
@@ -189,27 +199,35 @@ func TestParseImagePath(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
want *ParsedURL want *Request
wantErr bool wantErr bool
}{ }{
{ {
name: "chi wildcard capture", name: "chi wildcard capture",
input: "cdn.example.com/photos/cat.jpg/800x600.webp", input: "cdn.example.com/photos/cat.jpg/800x600.webp",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Source: Source{
Path: testPathCat, Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: testPathCat,
Format: FormatWebP, },
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
},
}, },
}, },
{ {
name: "with leading slash from chi", name: "with leading slash from chi",
input: "/cdn.example.com/photos/cat.jpg/800x600.webp", input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Source: Source{
Path: testPathCat, Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: testPathCat,
Format: FormatWebP, },
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
},
}, },
}, },
} }
@@ -229,7 +247,7 @@ func TestParseImagePath(t *testing.T) {
return return
} }
assertParsedURL(t, got, tt.want) assertRequest(t, got, tt.want)
}) })
} }
} }
@@ -246,14 +264,14 @@ func TestParseImagePath_LowerCasesHost(t *testing.T) {
t.Fatalf("ParseImagePath() error = %v", err) t.Fatalf("ParseImagePath() error = %v", err)
} }
t.Logf("parsed host %q, path %q", got.Host, got.Path) t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
if got.Host != testHostCDN { if got.Source.Host != testHostCDN {
t.Errorf("Host = %q, want %q", got.Host, testHostCDN) t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
} }
if got.Path != "/Photos/Cat.jpg" { if got.Source.Path != "/Photos/Cat.jpg" {
t.Errorf("Path = %q, want %q", got.Path, "/Photos/Cat.jpg") t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
} }
} }
@@ -277,40 +295,6 @@ func TestParseImagePath_RefusesUserinfo(t *testing.T) {
} }
} }
func TestParsedURL_ToImageRequest(t *testing.T) {
t.Parallel()
parsed := &ParsedURL{
Host: testHostCDN,
Path: testPathCat,
Query: "version=2",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}
req := parsed.ToImageRequest()
if req.SourceHost != parsed.Host {
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
}
if req.SourcePath != parsed.Path {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
}
if req.SourceQuery != parsed.Query {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
}
if req.Size != parsed.Size {
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
}
if req.Format != parsed.Format {
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
}
}
func TestParseImageURL_PathTraversal(t *testing.T) { func TestParseImageURL_PathTraversal(t *testing.T) {
t.Parallel() t.Parallel()
@@ -417,22 +401,24 @@ func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
want *ParsedURL want *Request
}{ }{
{ {
name: "path", name: "path",
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp", input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: "/sale/50%25off.jpg", Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
}, },
}, },
{ {
name: "query", name: "query",
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp", input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
want: &ParsedURL{ want: &Request{
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25", Source: Source{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
},
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
}, },
}, },
} }
@@ -446,7 +432,7 @@ func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
t.Fatalf("ParseImageURL() unexpected error = %v", err) t.Fatalf("ParseImageURL() unexpected error = %v", err)
} }
assertParsedURL(t, got, tt.want) assertRequest(t, got, tt.want)
}) })
} }
} }
@@ -1,4 +1,4 @@
package imgcache package imagerequest
import ( import (
"errors" "errors"
@@ -10,35 +10,43 @@ func TestValidateImageRequest(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
req ImageRequest req Request
wantErr error wantErr error
}{ }{
{ {
name: "within bounds", name: "within bounds",
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover}, req: Request{Transform: Transform{
Size: Size{Width: 800, Height: 600}, FitMode: FitCover,
}},
}, },
{ {
name: "original size and empty fit", name: "original size and empty fit",
req: ImageRequest{Size: Size{Width: 0, Height: 0}}, req: Request{Transform: Transform{Size: Size{Width: 0, Height: 0}}},
}, },
{ {
name: "width over limit", name: "width over limit",
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}}, req: Request{Transform: Transform{
Size: Size{Width: MaxDimension + 1, Height: 600},
}},
wantErr: ErrDimensionTooLarge, wantErr: ErrDimensionTooLarge,
}, },
{ {
name: "height over limit", name: "height over limit",
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}}, req: Request{Transform: Transform{
Size: Size{Width: 800, Height: MaxDimension + 1},
}},
wantErr: ErrDimensionTooLarge, wantErr: ErrDimensionTooLarge,
}, },
{ {
name: "negative width", name: "negative width",
req: ImageRequest{Size: Size{Width: -1, Height: 600}}, req: Request{Transform: Transform{Size: Size{Width: -1, Height: 600}}},
wantErr: ErrInvalidSize, wantErr: ErrInvalidSize,
}, },
{ {
name: "invalid fit mode", name: "invalid fit mode",
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"}, req: Request{Transform: Transform{
Size: Size{Width: 800, Height: 600}, FitMode: "bogus",
}},
wantErr: ErrInvalidFitMode, wantErr: ErrInvalidFitMode,
}, },
} }
+39
View File
@@ -0,0 +1,39 @@
package imagerequest
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
type VariantKey string
// CacheKey generates a unique key for a request variant.
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
// The path and query go through escapePercentAndColon, so that a ":" in
// either cannot be taken for the one between them.
func CacheKey(req *Request) VariantKey {
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
req.Source.Host,
escapePercentAndColon(req.Source.Path),
escapePercentAndColon(req.Source.Query),
req.Transform.Size.Width,
req.Transform.Size.Height,
req.Transform.Format,
req.Transform.Quality,
req.Transform.FitMode,
)
h := sha256.Sum256([]byte(data))
return VariantKey(hex.EncodeToString(h[:]))
}
// escapePercentAndColon returns s with each "%" written as "%25" and each
// ":" as "%3A", so that the result holds no ":".
func escapePercentAndColon(s string) string {
s = strings.ReplaceAll(s, "%", "%25")
return strings.ReplaceAll(s, ":", "%3A")
}
@@ -0,0 +1,142 @@
package imagerequest
import "testing"
func TestCacheKey(t *testing.T) {
t.Parallel()
req1 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Quality: 85, FitMode: FitCover,
},
}
req2 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Quality: 85, FitMode: FitCover,
},
}
// Same request should produce same key
key1 := CacheKey(req1)
key2 := CacheKey(req2)
if key1 != key2 {
t.Errorf("CacheKey() not deterministic: %s vs %s", key1, key2)
}
// Key should be 64 hex chars
if len(string(key1)) != 64 {
t.Errorf("CacheKey() length = %d, want 64", len(string(key1)))
}
// Different size should produce different key
req3 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 400, Height: 300}, Format: FormatWebP, // Different size
Quality: 85, FitMode: FitCover,
},
}
key3 := CacheKey(req3)
if key1 == key3 {
t.Error("CacheKey() produced same key for different sizes")
}
// Different format should produce different key
req4 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatPNG, // Different format
Quality: 85, FitMode: FitCover,
},
}
key4 := CacheKey(req4)
if key1 == key4 {
t.Error("CacheKey() produced same key for different formats")
}
// Different quality should produce different key
req5 := &Request{
Source: Source{Host: testHostCDN, Path: testPathCat, Query: ""},
Transform: Transform{
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Quality: 50, FitMode: FitCover, // Different quality
},
}
key5 := CacheKey(req5)
if key1 == key5 {
t.Error("CacheKey() produced same key for different quality")
}
}
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
// URLs whose path or query holds ":" or "%" get different keys.
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path1, query1 string
path2, query2 string
}{
{
name: "colon moved from path to query",
path1: "/a:b", query1: "",
path2: "/a", query2: "b:",
},
{
name: "colon in path and its percent-encoding",
path1: "/a:b", query1: "",
path2: "/a%3Ab", query2: "",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
key1 := CacheKey(&Request{
Source: Source{
Host: testHostCDN,
Path: tt.path1,
Query: tt.query1,
},
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
},
})
key2 := CacheKey(&Request{
Source: Source{
Host: testHostCDN,
Path: tt.path2,
Query: tt.query2,
},
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
},
})
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
if key1 == key2 {
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
}
})
}
}
+34 -28
View File
@@ -17,6 +17,7 @@ import (
lru "github.com/hashicorp/golang-lru/v2" lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// Cache errors. // Cache errors.
@@ -111,7 +112,7 @@ type Cache struct {
// metaCache holds the content types of the variants most recently // metaCache holds the content types of the variants most recently
// stored or served, so a hit does not read the variant's .meta file. // stored or served, so a hit does not read the variant's .meta file.
// It never stands in for the variant file, which is always opened. // It never stands in for the variant file, which is always opened.
metaCache *lru.Cache[VariantKey, string] metaCache *lru.Cache[imagerequest.VariantKey, string]
// contentLocks serializes StoreSource and evictSourceBlob per // contentLocks serializes StoreSource and evictSourceBlob per
// content hash, closing the race window between an eviction's row // content hash, closing the race window between an eviction's row
@@ -153,7 +154,7 @@ func newCache(
log = slog.Default() log = slog.Default()
} }
metaCache, err := lru.New[VariantKey, string](metaCacheSize) metaCache, err := lru.New[imagerequest.VariantKey, string](metaCacheSize)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create variant content type cache: %w", err) return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
} }
@@ -223,7 +224,7 @@ func newCache(
// LookupResult contains the result of a cache lookup. // LookupResult contains the result of a cache lookup.
type LookupResult struct { type LookupResult struct {
Hit bool Hit bool
CacheKey VariantKey CacheKey imagerequest.VariantKey
ContentType string ContentType string
SizeBytes int64 SizeBytes int64
CacheStatus CacheStatus CacheStatus CacheStatus
@@ -231,8 +232,10 @@ type LookupResult struct {
// Lookup checks if a processed variant exists on disk. Hits touch the // Lookup checks if a processed variant exists on disk. Hits touch the
// variant's LRU timestamp; a disabled cache always misses. // variant's LRU timestamp; a disabled cache always misses.
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) { func (c *Cache) Lookup(
cacheKey := CacheKey(req) ctx context.Context, req *imagerequest.Request,
) (*LookupResult, error) {
cacheKey := imagerequest.CacheKey(req)
// Check variant storage directly - no DB needed for cache hits // Check variant storage directly - no DB needed for cache hits
if !c.disabled && c.variants.Exists(cacheKey) { if !c.disabled && c.variants.Exists(cacheKey) {
@@ -257,7 +260,9 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
// variant's .meta file and is then kept in metaCache. A variant with // variant's .meta file and is then kept in metaCache. A variant with
// no .meta file is served as application/octet-stream, which is not // no .meta file is served as application/octet-stream, which is not
// kept. // kept.
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) { func (c *Cache) GetVariant(
cacheKey imagerequest.VariantKey,
) (io.ReadCloser, int64, string, error) {
if c.disabled { if c.disabled {
return nil, 0, "", ErrNotFound return nil, 0, "", ErrNotFound
} }
@@ -282,7 +287,7 @@ func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, e
// disabled cache it is a no-op returning an empty hash. // disabled cache it is a no-op returning an empty hash.
func (c *Cache) StoreSource( func (c *Cache) StoreSource(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *imagerequest.Request,
content io.Reader, content io.Reader,
result *httpfetcher.FetchResult, result *httpfetcher.FetchResult,
) (ContentHash, error) { ) (ContentHash, error) {
@@ -322,7 +327,7 @@ func (c *Cache) StoreSource(
} }
// Store in database // Store in database
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery) pathHash := HashPath(req.Source.Path + "?" + req.Source.Query)
headersJSON, err := json.Marshal(result.Headers) headersJSON, err := json.Marshal(result.Headers)
if err != nil { if err != nil {
@@ -349,7 +354,7 @@ func (c *Cache) StoreSource(
content_type = excluded.content_type, content_type = excluded.content_type,
response_headers = excluded.response_headers, response_headers = excluded.response_headers,
fetched_at = CURRENT_TIMESTAMP fetched_at = CURRENT_TIMESTAMP
`, req.SourceHost, req.SourcePath, req.SourceQuery, pathHash, `, req.Source.Host, req.Source.Path, req.Source.Query, pathHash,
contentHash, httpStatusOK, result.ContentType, string(headersJSON)) contentHash, httpStatusOK, result.ContentType, string(headersJSON))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to insert source metadata: %w", err) return "", fmt.Errorf("failed to insert source metadata: %w", err)
@@ -367,7 +372,8 @@ func (c *Cache) StoreSource(
// accounting insert is best-effort (the startup reconciliation pass // accounting insert is best-effort (the startup reconciliation pass
// adopts any variant file that misses its accounting row). // adopts any variant file that misses its accounting row).
func (c *Cache) StoreVariant( func (c *Cache) StoreVariant(
ctx context.Context, cacheKey VariantKey, content io.Reader, contentType string, ctx context.Context, cacheKey imagerequest.VariantKey,
content io.Reader, contentType string,
) error { ) error {
if c.disabled { if c.disabled {
return nil return nil
@@ -403,7 +409,7 @@ func (c *Cache) StoreVariant(
// if not. Hits touch the blob's LRU timestamp; a disabled cache always // if not. Hits touch the blob's LRU timestamp; a disabled cache always
// reports no cached source. // reports no cached source.
func (c *Cache) LookupSource( func (c *Cache) LookupSource(
ctx context.Context, req *ImageRequest, ctx context.Context, req *imagerequest.Request,
) (ContentHash, string, error) { ) (ContentHash, string, error) {
if c.disabled { if c.disabled {
return "", "", nil return "", "", nil
@@ -414,7 +420,7 @@ func (c *Cache) LookupSource(
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
SELECT content_hash, content_type FROM source_metadata SELECT content_hash, content_type FROM source_metadata
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&hashStr, &contentType) `, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&hashStr, &contentType)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return "", "", nil return "", "", nil
@@ -438,7 +444,7 @@ func (c *Cache) LookupSource(
// StoreNegative stores a negative cache entry for a failed fetch. // StoreNegative stores a negative cache entry for a failed fetch.
func (c *Cache) StoreNegative( func (c *Cache) StoreNegative(
ctx context.Context, req *ImageRequest, statusCode int, errMsg string, ctx context.Context, req *imagerequest.Request, statusCode int, errMsg string,
) error { ) error {
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL) expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
@@ -452,7 +458,7 @@ func (c *Cache) StoreNegative(
error_message = excluded.error_message, error_message = excluded.error_message,
fetched_at = CURRENT_TIMESTAMP, fetched_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at expires_at = excluded.expires_at
`, req.SourceHost, req.SourcePath, req.SourceQuery, statusCode, errMsg, expiresAt) `, req.Source.Host, req.Source.Path, req.Source.Query, statusCode, errMsg, expiresAt)
if err != nil { if err != nil {
return fmt.Errorf("failed to insert negative cache: %w", err) return fmt.Errorf("failed to insert negative cache: %w", err)
} }
@@ -462,14 +468,14 @@ func (c *Cache) StoreNegative(
// GetSourceMetadataID returns the source metadata ID for a request. // GetSourceMetadataID returns the source metadata ID for a request.
func (c *Cache) GetSourceMetadataID( func (c *Cache) GetSourceMetadataID(
ctx context.Context, req *ImageRequest, ctx context.Context, req *imagerequest.Request,
) (int64, error) { ) (int64, error) {
var id int64 var id int64
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
SELECT id FROM source_metadata SELECT id FROM source_metadata
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id) `, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&id)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to get source metadata ID: %w", err) return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
} }
@@ -665,7 +671,7 @@ func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) err
// application/octet-stream and keeps nothing, so metaCache only ever // application/octet-stream and keeps nothing, so metaCache only ever
// holds a type read from a .meta file or passed to StoreVariant. // holds a type read from a .meta file or passed to StoreVariant.
func (c *Cache) loadVariantWithMeta( func (c *Cache) loadVariantWithMeta(
cacheKey VariantKey, cacheKey imagerequest.VariantKey,
) (io.ReadCloser, int64, string, error) { ) (io.ReadCloser, int64, string, error) {
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey) reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
if err != nil { if err != nil {
@@ -685,15 +691,15 @@ func (c *Cache) loadVariantWithMeta(
// A failure is logged and is otherwise non-fatal; the metadata is in the // A failure is logged and is otherwise non-fatal; the metadata is in the
// database. // database.
func (c *Cache) writeMetadataSidecar( func (c *Cache) writeMetadataSidecar(
req *ImageRequest, req *imagerequest.Request,
pathHash PathHash, pathHash PathHash,
contentHash ContentHash, contentHash ContentHash,
result *httpfetcher.FetchResult, result *httpfetcher.FetchResult,
) { ) {
meta := &SourceMetadata{ meta := &SourceMetadata{
Host: req.SourceHost, Host: req.Source.Host,
Path: req.SourcePath, Path: req.Source.Path,
Query: req.SourceQuery, Query: req.Source.Query,
ContentHash: string(contentHash), ContentHash: string(contentHash),
StatusCode: result.StatusCode, StatusCode: result.StatusCode,
ContentType: result.ContentType, ContentType: result.ContentType,
@@ -704,16 +710,16 @@ func (c *Cache) writeMetadataSidecar(
RemoteAddr: result.RemoteAddr, RemoteAddr: result.RemoteAddr,
} }
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta) err := c.srcMetadata.Store(req.Source.Host, pathHash, meta)
if err != nil { if err != nil {
c.log.Warn("failed to write metadata sidecar", c.log.Warn("failed to write metadata sidecar",
"host", req.SourceHost, "path_hash", pathHash, "error", err) "host", req.Source.Host, "path_hash", pathHash, "error", err)
} }
} }
// touchVariant updates the LRU timestamp of a variant, best-effort: // touchVariant updates the LRU timestamp of a variant, best-effort:
// a failed touch only makes the entry look colder to eviction. // a failed touch only makes the entry look colder to eviction.
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) { func (c *Cache) touchVariant(ctx context.Context, cacheKey imagerequest.VariantKey) {
_, err := c.db.ExecContext(ctx, ` _, err := c.db.ExecContext(ctx, `
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
WHERE cache_key = ? WHERE cache_key = ?
@@ -739,14 +745,14 @@ func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash)
// checkNegativeCache checks if a request is in the negative cache. // checkNegativeCache checks if a request is in the negative cache.
func (c *Cache) checkNegativeCache( func (c *Cache) checkNegativeCache(
ctx context.Context, req *ImageRequest, ctx context.Context, req *imagerequest.Request,
) (bool, error) { ) (bool, error) {
var expiresAt time.Time var expiresAt time.Time
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
SELECT expires_at FROM negative_cache SELECT expires_at FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt) `, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&expiresAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return false, nil return false, nil
@@ -762,10 +768,10 @@ func (c *Cache) checkNegativeCache(
_, err = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
DELETE FROM negative_cache DELETE FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery) `, req.Source.Host, req.Source.Path, req.Source.Query)
if err != nil { if err != nil {
c.log.Warn("failed to delete expired negative cache entry", c.log.Warn("failed to delete expired negative cache entry",
"host", req.SourceHost, "path", req.SourcePath, "error", err) "host", req.Source.Host, "path", req.Source.Path, "error", err)
} }
return false, nil return false, nil
+78 -49
View File
@@ -10,6 +10,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
func setupTestDB(t *testing.T) *sql.DB { func setupTestDB(t *testing.T) *sql.DB {
@@ -117,13 +118,17 @@ func TestCache_LookupMiss(t *testing.T) {
cache := setupTestCache(t) cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: testPathCat, Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: testPathCat,
Format: FormatWebP, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
result, err := cache.Lookup(ctx, req) result, err := cache.Lookup(ctx, req)
@@ -146,13 +151,17 @@ func TestCache_StoreAndLookup(t *testing.T) {
cache := setupTestCache(t) cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: testPathCat, Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: testPathCat,
Format: FormatWebP, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Store source content // Store source content
@@ -173,7 +182,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
} }
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := imagerequest.CacheKey(req)
outputContent := []byte("fake webp data") outputContent := []byte("fake webp data")
err = cache.StoreVariant( err = cache.StoreVariant(
@@ -207,11 +216,15 @@ func TestCache_NegativeCache(t *testing.T) {
cache := setupTestCache(t) cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: "/photos/notfound.jpg", Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: "/photos/notfound.jpg",
Format: FormatWebP, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
},
} }
// Store negative cache entry // Store negative cache entry
@@ -248,11 +261,15 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: "/photos/expired.jpg", Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: "/photos/expired.jpg",
Format: FormatWebP, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
},
} }
// Store negative cache entry // Store negative cache entry
@@ -281,17 +298,21 @@ func TestCache_VariantLookup(t *testing.T) {
cache := setupTestCache(t) cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: "/photos/variant.jpg", Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: "/photos/variant.jpg",
Format: FormatWebP, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := imagerequest.CacheKey(req)
outputContent := []byte("output data") outputContent := []byte("output data")
err := cache.StoreVariant( err := cache.StoreVariant(
@@ -331,17 +352,21 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
cache := setupTestCache(t) cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: "/photos/variantct.jpg", Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: "/photos/variantct.jpg",
Format: FormatWebP, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := imagerequest.CacheKey(req)
outputContent := []byte("output webp data") outputContent := []byte("output webp data")
err := cache.StoreVariant( err := cache.StoreVariant(
@@ -383,17 +408,21 @@ func TestCache_GetVariant(t *testing.T) {
cache := setupTestCache(t) cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: "/photos/output.jpg", Host: testHostCDN,
Size: Size{Width: 800, Height: 600}, Path: "/photos/output.jpg",
Format: FormatWebP, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 800, Height: 600},
Format: imagerequest.FormatWebP,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := imagerequest.CacheKey(req)
outputContent := []byte("the actual output content") outputContent := []byte("the actual output content")
err := cache.StoreVariant( err := cache.StoreVariant(
@@ -5,6 +5,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts // TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
@@ -171,7 +173,8 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
storeEvictionTestSource(t, cache, "pages.example.com", storeEvictionTestSource(t, cache, "pages.example.com",
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100)) "/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)), storeEvictionTestVariant(t, cache,
imagerequest.VariantKey("aabbccdd000"+strconv.Itoa(i)),
bytes.Repeat(content, 10)) bytes.Repeat(content, 10))
} }
@@ -16,6 +16,7 @@ import (
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
) )
@@ -80,14 +81,20 @@ func setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher)
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the // photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
// given quality and fit mode. Each call returns a new request, as Get writes // given quality and fit mode. Each call returns a new request, as Get writes
// to the request it is given. // to the request it is given.
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest { func photoVariant(
return &ImageRequest{ fixtures *TestFixtures, quality int, fit imagerequest.Fit,
SourceHost: fixtures.GoodHost, ) *imagerequest.Request {
SourcePath: testPathPhoto, return &imagerequest.Request{
Size: Size{Width: 50, Height: 25}, Source: imagerequest.Source{
Format: FormatJPEG, Host: fixtures.GoodHost,
Quality: quality, Path: testPathPhoto,
FitMode: fit, },
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 50, Height: 25},
Format: imagerequest.FormatJPEG,
Quality: quality,
FitMode: fit,
},
} }
} }
@@ -100,7 +107,7 @@ type getResult struct {
// startGet calls Get in a goroutine of its own and delivers what it returned // startGet calls Get in a goroutine of its own and delivers what it returned
// on the channel. // on the channel.
func startGet( func startGet(
ctx context.Context, svc *Service, req *ImageRequest, ctx context.Context, svc *Service, req *imagerequest.Request,
) <-chan getResult { ) <-chan getResult {
results := make(chan getResult, 1) results := make(chan getResult, 1)
@@ -146,7 +153,7 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
pending := make([]<-chan getResult, 0, requests) pending := make([]<-chan getResult, 0, requests)
for range requests { for range requests {
pending = append(pending, pending = append(pending,
startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))) startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover)))
} }
<-fetcher.started <-fetcher.started
@@ -199,9 +206,11 @@ func TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
svc, fixtures, fetcher := setupHeldFetchService(t) svc, fixtures, fetcher := setupHeldFetchService(t)
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)) cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover)) lowQuality := startGet(t.Context(), svc,
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitContain)) photoVariant(fixtures, 40, imagerequest.FitCover))
contain := startGet(t.Context(), svc,
photoVariant(fixtures, 85, imagerequest.FitContain))
for range 3 { for range 3 {
select { select {
@@ -253,12 +262,12 @@ func TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
svc, fixtures, fetcher := setupHeldFetchService(t) svc, fixtures, fetcher := setupHeldFetchService(t)
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)) first := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
<-fetcher.started <-fetcher.started
waiterCtx, cancelWaiter := context.WithCancel(t.Context()) waiterCtx, cancelWaiter := context.WithCancel(t.Context())
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, FitCover)) waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
time.Sleep(arrivalWait) time.Sleep(arrivalWait)
cancelWaiter() cancelWaiter()
@@ -300,11 +309,11 @@ func TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
svc, fixtures, fetcher := setupHeldFetchService(t) svc, fixtures, fetcher := setupHeldFetchService(t)
firstCtx, cancelFirst := context.WithCancel(t.Context()) firstCtx, cancelFirst := context.WithCancel(t.Context())
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover)) first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
<-fetcher.started <-fetcher.started
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)) second := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
time.Sleep(arrivalWait) time.Sleep(arrivalWait)
cancelFirst() cancelFirst()
@@ -355,9 +364,9 @@ func TestService_Get_ConcurrentMissesShareAFailure(t *testing.T) {
svc, fixtures, fetcher := setupHeldFetchService(t) svc, fixtures, fetcher := setupHeldFetchService(t)
request := func() *ImageRequest { request := func() *imagerequest.Request {
req := photoVariant(fixtures, 85, FitCover) req := photoVariant(fixtures, 85, imagerequest.FitCover)
req.SourcePath = tc.path req.Source.Path = tc.path
return req return req
} }
@@ -399,7 +408,7 @@ func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
cancel() cancel()
_, err := svc.Get(ctx, photoVariant(fixtures, 85, FitCover)) _, err := svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
t.Errorf("Get() error = %v, want %v", err, context.Canceled) t.Errorf("Get() error = %v, want %v", err, context.Canceled)
} }
@@ -423,7 +432,7 @@ func TestService_Get_ReturnsByItsDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), timeout) ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel() defer cancel()
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover)) results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
select { select {
case got := <-results: case got := <-results:
@@ -456,11 +465,11 @@ func TestService_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout) firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
defer cancelFirst() defer cancelFirst()
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover)) first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
<-fetcher.started <-fetcher.started
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover)) waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
time.Sleep(arrivalWait) time.Sleep(arrivalWait)
fetcher.releaseFetches() fetcher.releaseFetches()
@@ -503,7 +512,7 @@ func TestService_Get_PanicBecomesAnError(t *testing.T) {
} }
}() }()
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover)) _, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
}() }()
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") { if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
@@ -540,7 +549,7 @@ func TestService_Get_PanicIsReportedToSentry(t *testing.T) {
} }
}() }()
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, FitCover)) _, _ = svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
}() }()
events := transport.Events() events := transport.Events()
+14 -10
View File
@@ -11,6 +11,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// DefaultEvictionInterval is how often the background evictor checks // DefaultEvictionInterval is how often the background evictor checks
@@ -85,7 +87,7 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
// source content blob (identified by contentHash). // source content blob (identified by contentHash).
type evictionCandidate struct { type evictionCandidate struct {
isVariant bool isVariant bool
cacheKey VariantKey cacheKey imagerequest.VariantKey
contentHash ContentHash contentHash ContentHash
sizeBytes int64 sizeBytes int64
lastAccessedAt string lastAccessedAt string
@@ -240,7 +242,7 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
return nil, fmt.Errorf("failed to scan variant candidate: %w", err) return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
} }
candidate.cacheKey = VariantKey(key) candidate.cacheKey = imagerequest.VariantKey(key)
candidates = append(candidates, candidate) candidates = append(candidates, candidate)
} }
@@ -300,7 +302,9 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
// file. The metaCache entry goes before the files; a GetVariant that // file. The metaCache entry goes before the files; a GetVariant that
// read them just before may put it back, and the next GetVariant then // read them just before may put it back, and the next GetVariant then
// fails to open the file and removes it again. // fails to open the file and removes it again.
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error { func (c *Cache) evictVariant(
ctx context.Context, cacheKey imagerequest.VariantKey,
) error {
_, err := c.db.ExecContext(ctx, _, err := c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey)) `DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
if err != nil { if err != nil {
@@ -648,7 +652,7 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
return nil return nil
} }
return c.reconcileVariantFile(ctx, path, entry, VariantKey(name)) return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
}, },
) )
} }
@@ -659,7 +663,7 @@ 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, entry fs.DirEntry, cacheKey VariantKey, ctx context.Context, path string, entry fs.DirEntry, cacheKey imagerequest.VariantKey,
) error { ) error {
info, err := entry.Info() info, err := entry.Info()
if err != nil { if err != nil {
@@ -691,7 +695,7 @@ func (c *Cache) reconcileVariantFile(
// adoptVariantFile inserts an accounting row for a variant file that // adoptVariantFile inserts an accounting row for a variant file that
// has none, using the file's size and modification time. // has none, using the file's size and modification time.
func (c *Cache) adoptVariantFile( func (c *Cache) adoptVariantFile(
ctx context.Context, path string, info fs.FileInfo, cacheKey VariantKey, ctx context.Context, path string, info fs.FileInfo, cacheKey imagerequest.VariantKey,
) error { ) error {
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout) modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
contentType := c.variantContentTypeFromSidecar(path) contentType := c.variantContentTypeFromSidecar(path)
@@ -731,7 +735,7 @@ func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
// reconcileVariantRows drops accounting rows whose variant files are // reconcileVariantRows drops accounting rows whose variant files are
// missing, so the database never references deleted content. // missing, so the database never references deleted content.
func (c *Cache) reconcileVariantRows(ctx context.Context) error { func (c *Cache) reconcileVariantRows(ctx context.Context) error {
var after VariantKey var after imagerequest.VariantKey
for { for {
keys, err := c.variantKeysAfter(ctx, after) keys, err := c.variantKeysAfter(ctx, after)
@@ -772,9 +776,9 @@ func (c *Cache) reconcileVariantRows(ctx context.Context) error {
// variantKeysAfter returns, in order, up to c.reconciliationPageSize // variantKeysAfter returns, in order, up to c.reconciliationPageSize
// tracked variant cache keys that sort after the given one. // tracked variant cache keys that sort after the given one.
func (c *Cache) variantKeysAfter( func (c *Cache) variantKeysAfter(
ctx context.Context, after VariantKey, ctx context.Context, after imagerequest.VariantKey,
) ([]VariantKey, error) { ) ([]imagerequest.VariantKey, error) {
return queryStringColumn[VariantKey](ctx, c.db, ` return queryStringColumn[imagerequest.VariantKey](ctx, c.db, `
SELECT cache_key FROM variant_content SELECT cache_key FROM variant_content
WHERE cache_key > ? ORDER BY cache_key LIMIT ? WHERE cache_key > ? ORDER BY cache_key LIMIT ?
`, "variant keys", "variant key", string(after), c.reconciliationPageSize) `, "variant keys", "variant key", string(after), c.reconciliationPageSize)
+49 -34
View File
@@ -16,6 +16,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP // sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
@@ -27,10 +28,10 @@ const sqliteTimestampFormat = "2006-01-02 15:04:05"
// keys reused across the eviction tests, in the order the tests store // keys reused across the eviction tests, in the order the tests store
// them. // them.
const ( const (
testVariantKeyOne VariantKey = "aabbccdd0001" testVariantKeyOne imagerequest.VariantKey = "aabbccdd0001"
testVariantKeyTwo VariantKey = "aabbccdd0002" testVariantKeyTwo imagerequest.VariantKey = "aabbccdd0002"
testVariantKeyThree VariantKey = "aabbccdd0003" testVariantKeyThree imagerequest.VariantKey = "aabbccdd0003"
testVariantKeyFour VariantKey = "aabbccdd0004" testVariantKeyFour imagerequest.VariantKey = "aabbccdd0004"
) )
// evictionTestDB creates an in-memory SQLite database with the real // evictionTestDB creates an in-memory SQLite database with the real
@@ -88,12 +89,16 @@ func storeEvictionTestSource(
) ContentHash { ) ContentHash {
t.Helper() t.Helper()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: host, Source: imagerequest.Source{
SourcePath: path, Host: host,
Format: FormatJPEG, Path: path,
Quality: 85, },
FitMode: FitCover, Transform: imagerequest.Transform{
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
result := &httpfetcher.FetchResult{ result := &httpfetcher.FetchResult{
@@ -118,7 +123,7 @@ func storeEvictionTestSource(
// storeEvictionTestVariant stores content as a processed variant under // storeEvictionTestVariant stores content as a processed variant under
// the given cache key. // the given cache key.
func storeEvictionTestVariant( func storeEvictionTestVariant(
t *testing.T, cache *Cache, key VariantKey, content []byte, t *testing.T, cache *Cache, key imagerequest.VariantKey, content []byte,
) { ) {
t.Helper() t.Helper()
@@ -131,7 +136,7 @@ func storeEvictionTestVariant(
// setVariantLastAccessed backdates the last access time of a tracked // setVariantLastAccessed backdates the last access time of a tracked
// variant, to make LRU ordering deterministic in tests. // variant, to make LRU ordering deterministic in tests.
func setVariantLastAccessed( func setVariantLastAccessed(
t *testing.T, cache *Cache, key VariantKey, when time.Time, t *testing.T, cache *Cache, key imagerequest.VariantKey, when time.Time,
) { ) {
t.Helper() t.Helper()
@@ -244,7 +249,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)
} }
if !cache.variants.Exists(VariantKey(key)) { if !cache.variants.Exists(imagerequest.VariantKey(key)) {
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)
} }
} }
@@ -343,7 +348,7 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
cache, _ := newEvictionTestCache(t, limit) cache, _ := newEvictionTestCache(t, limit)
now := time.Now() now := time.Now()
keys := []VariantKey{ keys := []imagerequest.VariantKey{
testVariantKeyOne, testVariantKeyTwo, testVariantKeyOne, testVariantKeyTwo,
testVariantKeyThree, testVariantKeyFour, testVariantKeyThree, testVariantKeyFour,
} }
@@ -516,12 +521,16 @@ func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
cache, tmpDir := newEvictionTestCache(t, 0) cache, tmpDir := newEvictionTestCache(t, 0)
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: "src.example.com", Source: imagerequest.Source{
SourcePath: "/a.jpg", Host: "src.example.com",
Format: FormatJPEG, Path: "/a.jpg",
Quality: 85, },
FitMode: FitCover, Transform: imagerequest.Transform{
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
assertDisabledCacheWritesAreNoOps(t, cache, req) assertDisabledCacheWritesAreNoOps(t, cache, req)
@@ -533,14 +542,14 @@ func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
// assertDisabledCacheWritesAreNoOps verifies that stores against a // assertDisabledCacheWritesAreNoOps verifies that stores against a
// disabled cache report success without recording anything. // disabled cache report success without recording anything.
func assertDisabledCacheWritesAreNoOps( func assertDisabledCacheWritesAreNoOps(
t *testing.T, cache *Cache, req *ImageRequest, t *testing.T, cache *Cache, req *imagerequest.Request,
) { ) {
t.Helper() t.Helper()
ctx := t.Context() ctx := t.Context()
err := cache.StoreVariant( err := cache.StoreVariant(
ctx, CacheKey(req), bytes.NewReader([]byte("data")), "image/webp", ctx, imagerequest.CacheKey(req), bytes.NewReader([]byte("data")), "image/webp",
) )
if err != nil { if err != nil {
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err) t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
@@ -566,7 +575,7 @@ func assertDisabledCacheWritesAreNoOps(
// assertDisabledCacheReadsAlwaysMiss verifies that lookups against a // assertDisabledCacheReadsAlwaysMiss verifies that lookups against a
// disabled cache never report a hit. // disabled cache never report a hit.
func assertDisabledCacheReadsAlwaysMiss( func assertDisabledCacheReadsAlwaysMiss(
t *testing.T, cache *Cache, req *ImageRequest, t *testing.T, cache *Cache, req *imagerequest.Request,
) { ) {
t.Helper() t.Helper()
@@ -661,7 +670,7 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
cache.StartEviction(time.Hour) cache.StartEviction(time.Hour)
defer func() { _ = cache.StopEviction(t.Context()) }() defer func() { _ = cache.StopEviction(t.Context()) }()
keys := []VariantKey{ keys := []imagerequest.VariantKey{
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree, testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
} }
fills := []byte{0x11, 0x12, 0x13} fills := []byte{0x11, 0x12, 0x13}
@@ -715,7 +724,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
keys := []VariantKey{ keys := []imagerequest.VariantKey{
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree, testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
} }
fills := []byte{0x21, 0x22, 0x23} fills := []byte{0x21, 0x22, 0x23}
@@ -1308,9 +1317,11 @@ func TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(t *testing.T)
assertNoDanglingReferences(t, cache) assertNoDanglingReferences(t, cache)
dupHash, _, err := cache.LookupSource(ctx, &ImageRequest{ dupHash, _, err := cache.LookupSource(ctx, &imagerequest.Request{
SourceHost: "race.example.com", Source: imagerequest.Source{
SourcePath: "/dup.jpg", Host: "race.example.com",
Path: "/dup.jpg",
},
}) })
if err != nil { if err != nil {
t.Fatalf("LookupSource failed: %v", err) t.Fatalf("LookupSource failed: %v", err)
@@ -1336,12 +1347,16 @@ func storeIdenticalContentConcurrently(
storeDone := make(chan error, 1) storeDone := make(chan error, 1)
go func() { go func() {
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: "race.example.com", Source: imagerequest.Source{
SourcePath: "/dup.jpg", Host: "race.example.com",
Format: FormatJPEG, Path: "/dup.jpg",
Quality: 85, },
FitMode: FitCover, Transform: imagerequest.Transform{
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
result := &httpfetcher.FetchResult{ result := &httpfetcher.FetchResult{
StatusCode: 200, StatusCode: 200,
+4 -126
View File
@@ -3,134 +3,12 @@ package imgcache
import ( import (
"context" "context"
"errors"
"io" "io"
"net/url"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// ImageFormat represents supported output image formats.
type ImageFormat string
// Supported image output formats.
const (
FormatOriginal ImageFormat = "orig"
FormatJPEG ImageFormat = "jpeg"
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif"
// FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each
// request from its Accept header once the URL's signature or token has
// been checked; it is never processed or cached as itself.
FormatAuto ImageFormat = "auto"
)
// Size represents requested image dimensions
type Size struct {
Width int
Height int
}
// OriginalSize returns true if this represents "keep original size"
func (s Size) OriginalSize() bool {
return s.Width == 0 && s.Height == 0
}
// FitMode represents how to fit image into requested dimensions.
type FitMode string
// Supported image fit modes.
const (
FitCover FitMode = "cover"
FitContain FitMode = "contain"
FitFill FitMode = "fill"
FitInside FitMode = "inside"
FitOutside FitMode = "outside"
)
// ValidateFitMode checks if the given fit mode is valid.
// Returns ErrInvalidFitMode for unrecognized fit modes.
func ValidateFitMode(fit FitMode) error {
switch fit {
case FitCover, FitContain, FitFill, FitInside, FitOutside, "":
return nil
default:
return ErrInvalidFitMode
}
}
// ValidateImageRequest checks a request's width and height with
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
// /v1/image/ route and the encrypted /v1/e/ route validate through this
// function so a request from either source enforces identical bounds.
func ValidateImageRequest(req *ImageRequest) error {
err := ValidateDimension("width", req.Size.Width)
if err != nil {
return err
}
err = ValidateDimension("height", req.Size.Height)
if err != nil {
return err
}
return ValidateFitMode(req.FitMode)
}
// ImageRequest represents a request for a processed image
type ImageRequest struct {
// SourceHost is the origin host (e.g., "cdn.example.com")
SourceHost string
// SourcePath is the path on the origin with its percent-escapes decoded,
// as url.URL's Path holds it (e.g., "/photos/my cat.jpg")
SourcePath string
// SourceQuery is the optional query string for the origin URL as the URL
// writes it, percent-escapes included, as url.URL's RawQuery holds it
SourceQuery string
// Size is the requested output dimensions
Size Size
// Format is the requested output format
Format ImageFormat
// Quality is the output quality (1-100) for lossy formats
Quality int
// FitMode is how to fit the image into requested dimensions
FitMode FitMode
// Signature is the HMAC signature for non-allowlisted hosts
Signature string
// Expires is when the URL expires: the exp of a signed URL, or the expiry
// of an encrypted URL; the zero time if it has none
Expires time.Time
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
AllowHTTP bool
}
// SourceURL returns the full upstream URL to fetch: the source path
// percent-encoded again, as net/url writes a path, so each source path is one
// upstream path, and the source query as it is, with the characters
// escapeQuery encodes percent-encoded. Uses http:// scheme when AllowHTTP is
// true, otherwise https://.
func (r *ImageRequest) SourceURL() string {
scheme := "https"
if r.AllowHTTP {
scheme = "http"
}
sourceURL := url.URL{
Scheme: scheme,
Host: r.SourceHost,
Path: r.SourcePath,
RawQuery: escapeQuery(r.SourceQuery),
}
return sourceURL.String()
}
// ImageResponse represents a processed image ready to serve // ImageResponse represents a processed image ready to serve
type ImageResponse struct { type ImageResponse struct {
// Content is the image data reader // Content is the image data reader
@@ -162,10 +40,10 @@ const (
// ImageCache is the main interface for the image caching proxy // ImageCache is the main interface for the image caching proxy
type ImageCache interface { type ImageCache interface {
// Get retrieves a processed image, fetching and processing if necessary // Get retrieves a processed image, fetching and processing if necessary
Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) Get(ctx context.Context, req *imagerequest.Request) (*ImageResponse, error)
// Warm pre-fetches and caches an image without returning it // Warm pre-fetches and caches an image without returning it
Warm(ctx context.Context, req *ImageRequest) error Warm(ctx context.Context, req *imagerequest.Request) error
// Stats returns cache statistics // Stats returns cache statistics
Stats(ctx context.Context) (*CacheStats, error) Stats(ctx context.Context) (*CacheStats, error)
@@ -9,18 +9,23 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// widthOnlyRequest asks for the test photo at width, its height scaled to // widthOnlyRequest asks for the test photo at width, its height scaled to
// keep the photo's aspect ratio. // keep the photo's aspect ratio.
func widthOnlyRequest(fixtures *TestFixtures, width int) *ImageRequest { func widthOnlyRequest(fixtures *TestFixtures, width int) *imagerequest.Request {
return &ImageRequest{ return &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: width}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: width},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
} }
@@ -36,7 +41,7 @@ func holdProcessingSlot(
input, feed := io.Pipe() input, feed := io.Pipe()
go func() { go func() {
_, _ = proc.Process(t.Context(), input, &imageprocessor.Request{}) _, _ = proc.Process(t.Context(), input, &imagerequest.Transform{})
}() }()
_, err := feed.Write([]byte{0}) _, err := feed.Write([]byte{0})
+29 -17
View File
@@ -10,23 +10,31 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// webpRequest returns a request for a 100x100 WebP variant of path. // webpRequest returns a request for a 100x100 WebP variant of path.
func webpRequest(path string) *ImageRequest { func webpRequest(path string) *imagerequest.Request {
return &ImageRequest{ return &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: path, Host: testHostCDN,
Size: Size{Width: 100, Height: 100}, Path: path,
Format: FormatWebP, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 100, Height: 100},
Format: imagerequest.FormatWebP,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
} }
// assertVariantServed checks that GetVariant serves key with the given // assertVariantServed checks that GetVariant serves key with the given
// content and the image/webp content type storeEvictionTestVariant stores. // content and the image/webp content type storeEvictionTestVariant stores.
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) { func assertVariantServed(
t *testing.T, cache *Cache, key imagerequest.VariantKey, content []byte,
) {
t.Helper() t.Helper()
reader, size, contentType, err := cache.GetVariant(key) reader, size, contentType, err := cache.GetVariant(key)
@@ -57,7 +65,9 @@ func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []b
// assertVariantNotFound checks that GetVariant refuses key with // assertVariantNotFound checks that GetVariant refuses key with
// ErrNotFound. // ErrNotFound.
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) { func assertVariantNotFound(
t *testing.T, cache *Cache, key imagerequest.VariantKey,
) {
t.Helper() t.Helper()
reader, _, _, err := cache.GetVariant(key) reader, _, _, err := cache.GetVariant(key)
@@ -71,16 +81,18 @@ func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
} }
// assertLookupMisses checks that Lookup reports request as a miss. // assertLookupMisses checks that Lookup reports request as a miss.
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) { func assertLookupMisses(
t *testing.T, cache *Cache, request *imagerequest.Request,
) {
t.Helper() t.Helper()
lookup, err := cache.Lookup(t.Context(), request) lookup, err := cache.Lookup(t.Context(), request)
if err != nil { if err != nil {
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err) t.Fatalf("Lookup(%s) error = %v", request.Source.Path, err)
} }
if lookup.Hit { if lookup.Hit {
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath) t.Errorf("Lookup(%s) is a hit, want a miss", request.Source.Path)
} }
} }
@@ -251,8 +263,8 @@ func TestEvictedVariantIsNotServed(t *testing.T) {
oldRequest := webpRequest("/old.jpg") oldRequest := webpRequest("/old.jpg")
newRequest := webpRequest("/new.jpg") newRequest := webpRequest("/new.jpg")
oldKey := CacheKey(oldRequest) oldKey := imagerequest.CacheKey(oldRequest)
newKey := CacheKey(newRequest) newKey := imagerequest.CacheKey(newRequest)
oldContent := bytes.Repeat([]byte{0x01}, 1000) oldContent := bytes.Repeat([]byte{0x01}, 1000)
newContent := bytes.Repeat([]byte{0x02}, 1000) newContent := bytes.Repeat([]byte{0x02}, 1000)
@@ -280,7 +292,7 @@ func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
cache, _ := newEvictionTestCache(t, 1<<20) cache, _ := newEvictionTestCache(t, 1<<20)
request := webpRequest("/deleted.jpg") request := webpRequest("/deleted.jpg")
key := CacheKey(request) key := imagerequest.CacheKey(request)
content := []byte("webp variant bytes") content := []byte("webp variant bytes")
storeEvictionTestVariant(t, cache, key, content) storeEvictionTestVariant(t, cache, key, content)
@@ -307,7 +319,7 @@ func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
for goroutine := range 8 { for goroutine := range 8 {
wg.Go(func() { wg.Go(func() {
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine)) key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
content := []byte(key) content := []byte(key)
for range 20 { for range 20 {
@@ -6,6 +6,8 @@ import (
"strconv" "strconv"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
func TestNegativeCache_StoreAndCheck(t *testing.T) { func TestNegativeCache_StoreAndCheck(t *testing.T) {
@@ -23,9 +25,11 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostExample, Source: imagerequest.Source{
SourcePath: "/missing.jpg", Host: testHostExample,
Path: "/missing.jpg",
},
} }
// Initially should not be in negative cache // Initially should not be in negative cache
@@ -70,9 +74,11 @@ func TestNegativeCache_Expired(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: testHostExample, Source: imagerequest.Source{
SourcePath: "/expired.jpg", Host: testHostExample,
Path: "/expired.jpg",
},
} }
// Store a negative entry with very short TTL // Store a negative entry with very short TTL
@@ -106,20 +112,21 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover)) resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
if err != nil { if err != nil {
t.Fatalf("Get() first request error = %v", err) t.Fatalf("Get() first request error = %v", err)
} }
_ = resp.Content.Close() _ = resp.Content.Close()
err = svc.cache.StoreNegative(t.Context(), photoVariant(fixtures, 85, FitCover), err = svc.cache.StoreNegative(t.Context(),
photoVariant(fixtures, 85, imagerequest.FitCover),
httpStatusBadGateway, "upstream answered 503") httpStatusBadGateway, "upstream answered 503")
if err != nil { if err != nil {
t.Fatalf("StoreNegative() error = %v", err) t.Fatalf("StoreNegative() error = %v", err)
} }
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, FitCover)) resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
if err != nil { if err != nil {
t.Fatalf("Get() of the cached variant error = %v", err) t.Fatalf("Get() of the cached variant error = %v", err)
} }
@@ -131,7 +138,7 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
resp.CacheStatus, CacheHit) resp.CacheStatus, CacheHit)
} }
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover)) resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
t.Logf("Get() of a variant made from the cached source: error = %v", err) t.Logf("Get() of a variant made from the cached source: error = %v", err)
if err != nil { if err != nil {
@@ -141,7 +148,7 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
_ = resp.Content.Close() _ = resp.Content.Close()
contentHash, _, err := svc.cache.LookupSource(t.Context(), contentHash, _, err := svc.cache.LookupSource(t.Context(),
photoVariant(fixtures, 85, FitCover)) photoVariant(fixtures, 85, imagerequest.FitCover))
if err != nil || contentHash == "" { if err != nil || contentHash == "" {
t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err) t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err)
} }
@@ -151,7 +158,7 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
t.Fatalf("evictSourceBlob() error = %v", err) t.Fatalf("evictSourceBlob() error = %v", err)
} }
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, FitCover)) _, err = svc.Get(t.Context(), photoVariant(fixtures, 60, imagerequest.FitCover))
t.Logf("Get() of a variant whose source was evicted: error = %v", err) t.Logf("Get() of a variant whose source was evicted: error = %v", err)
if !errors.Is(err, ErrNegativeCached) { if !errors.Is(err, ErrNegativeCached) {
@@ -190,8 +197,8 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
t.Fatalf("failed to create cache: %v", err) t.Fatalf("failed to create cache: %v", err)
} }
expired := &ImageRequest{ expired := &imagerequest.Request{
SourceHost: testHostExample, SourcePath: "/gone.jpg", Source: imagerequest.Source{Host: testHostExample, Path: "/gone.jpg"},
} }
err = cache.StoreNegative(t.Context(), expired, 404, "not found") err = cache.StoreNegative(t.Context(), expired, 404, "not found")
@@ -215,7 +222,7 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
cache.StartEviction(100 * time.Millisecond) cache.StartEviction(100 * time.Millisecond)
defer func() { _ = cache.StopEviction(t.Context()) }() defer func() { _ = cache.StopEviction(t.Context()) }()
for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 { for negativeCacheRowCount(t, cache, expired.Source.Path) > 0 {
if time.Since(started) > 10*time.Second { if time.Since(started) > 10*time.Second {
t.Fatal("the expired row is still in negative_cache") t.Fatal("the expired row is still in negative_cache")
} }
@@ -10,23 +10,27 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// 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
// its cache status. // its cache status.
func getImage(t *testing.T, svc *Service, req *ImageRequest) ([]byte, CacheStatus) { func getImage(
t *testing.T, svc *Service, req *imagerequest.Request,
) ([]byte, CacheStatus) {
t.Helper() t.Helper()
resp, err := svc.Get(t.Context(), req) resp, err := svc.Get(t.Context(), req)
if err != nil { if err != nil {
t.Fatalf("Get() of quality %d error = %v", req.Quality, err) t.Fatalf("Get() of quality %d error = %v", req.Transform.Quality, err)
} }
defer func() { _ = resp.Content.Close() }() defer func() { _ = resp.Content.Close() }()
body, err := io.ReadAll(resp.Content) body, err := io.ReadAll(resp.Content)
if err != nil { if err != nil {
t.Fatalf("reading the image of quality %d: %v", req.Quality, err) t.Fatalf("reading the image of quality %d: %v", req.Transform.Quality, err)
} }
return body, resp.CacheStatus return body, resp.CacheStatus
@@ -58,7 +62,9 @@ func truncateFile(t *testing.T, path string, size int64) {
} }
// cachedSourcePath returns the path of the cached source file req uses. // cachedSourcePath returns the path of the cached source file req uses.
func cachedSourcePath(t *testing.T, svc *Service, req *ImageRequest) string { func cachedSourcePath(
t *testing.T, svc *Service, req *imagerequest.Request,
) string {
t.Helper() t.Helper()
contentHash, _, err := svc.cache.LookupSource(t.Context(), req) contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
@@ -113,16 +119,16 @@ func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
getImage(t, svc, photoVariant(fixtures, 85, FitCover)) getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover)) path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
wholeSize := fileSize(t, path) wholeSize := fileSize(t, path)
truncateFile(t, path, 0) truncateFile(t, path, 0)
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path) assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
getImage(t, svc, photoVariant(fixtures, 40, FitCover)) getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
getImage(t, svc, photoVariant(fixtures, 30, FitCover)) getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches) t.Logf("upstream fetches = %d", fetches)
@@ -149,16 +155,16 @@ func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
getImage(t, svc, photoVariant(fixtures, 85, FitCover)) getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover)) path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
wholeSize := fileSize(t, path) wholeSize := fileSize(t, path)
truncateFile(t, path, wholeSize/2) truncateFile(t, path, wholeSize/2)
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path) assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
getImage(t, svc, photoVariant(fixtures, 40, FitCover)) getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
getImage(t, svc, photoVariant(fixtures, 30, FitCover)) getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches) t.Logf("upstream fetches = %d", fetches)
@@ -182,11 +188,11 @@ func assertConvertedAgain(t *testing.T, size int64) {
t.Helper() t.Helper()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
req := photoVariant(fixtures, 85, FitCover) req := photoVariant(fixtures, 85, imagerequest.FitCover)
want, _ := getImage(t, svc, req) want, _ := getImage(t, svc, req)
path := svc.cache.variants.keyToPath(CacheKey(req)) path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
truncateFile(t, path, size) truncateFile(t, path, size)
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path) assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
@@ -6,6 +6,8 @@ import (
"errors" "errors"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// pendingCountsWait is how long a test waits for the database's connection, // pendingCountsWait is how long a test waits for the database's connection,
@@ -92,7 +94,7 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
deadline, _ := ctx.Deadline() deadline, _ := ctx.Deadline()
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover)) results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
// The request has made its database reads by the time it fetches. // The request has made its database reads by the time it fetches.
select { select {
+38 -64
View File
@@ -19,6 +19,7 @@ import (
"sneak.berlin/go/pixa/internal/allowlist" "sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -139,7 +140,9 @@ var ErrProcessingTimedOut = errors.New(
// While its source URL is in the negative cache, a cached variant is still // While its source URL is in the negative cache, a cached variant is still
// served and a variant not in the cache is still made from a cached source; // served and a variant not in the cache is still made from a cached source;
// only a request that needs the source fetched is refused. // only a request that needs the source fetched is refused.
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) { func (s *Service) Get(
ctx context.Context, req *imagerequest.Request,
) (*ImageResponse, error) {
// Propagate AllowHTTP setting to the request // Propagate AllowHTTP setting to the request
req.AllowHTTP = s.allowHTTP req.AllowHTTP = s.allowHTTP
@@ -186,7 +189,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
} }
// Warm pre-fetches and caches an image without returning it. // Warm pre-fetches and caches an image without returning it.
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error { func (s *Service) Warm(ctx context.Context, req *imagerequest.Request) error {
_, err := s.Get(ctx, req) _, err := s.Get(ctx, req)
return err return err
@@ -204,7 +207,7 @@ func (s *Service) WaitForProcessing(ctx context.Context) int {
} }
// ValidateRequest validates the request signature if required. // ValidateRequest validates the request signature if required.
func (s *Service) ValidateRequest(req *ImageRequest) error { func (s *Service) ValidateRequest(req *imagerequest.Request) error {
// Check if host is allowed (no signature required) // Check if host is allowed (no signature required)
sourceURL := req.SourceURL() sourceURL := req.SourceURL()
@@ -227,7 +230,7 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
} }
// Signature required for non-allowed hosts // Signature required for non-allowed hosts
return s.signer.Verify(signatureRequest(req)) return s.signer.Verify(req)
} }
// GenerateSignedURL generates a signed URL for the given request. The URL // GenerateSignedURL generates a signed URL for the given request. The URL
@@ -236,26 +239,22 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
// set to 85 or cover, the values the route uses when a URL has no q or fit. // set to 85 or cover, the values the route uses when a URL has no q or fit.
func (s *Service) GenerateSignedURL( func (s *Service) GenerateSignedURL(
baseURL string, baseURL string,
req *ImageRequest, req *imagerequest.Request,
ttl time.Duration, ttl time.Duration,
) (string, error) { ) (string, error) {
if req.Quality == 0 { if req.Transform.Quality == 0 {
req.Quality = 85 req.Transform.Quality = 85
} }
if req.FitMode == "" { if req.Transform.FitMode == "" {
req.FitMode = FitCover req.Transform.FitMode = imagerequest.FitCover
} }
sigReq := signatureRequest(req) // Sets the request's Expires and Signature
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl) path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
// Propagate the generated signature and expiration back onto the request.
req.Expires = sigReq.Expires
req.Signature = sigReq.Signature
return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s", return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s",
baseURL, path, sig, exp, req.Quality, req.FitMode), nil baseURL, path, sig, exp, req.Transform.Quality, req.Transform.FitMode), nil
} }
// errPanicked is returned when processing a variant panicked. // errPanicked is returned when processing a variant panicked.
@@ -268,14 +267,14 @@ var errPanicked = errors.New("panic while processing image")
// the first request's cancellation, so the others are still served if that // the first request's cancellation, so the others are still served if that
// client goes away, but keeps its deadline. // client goes away, but keeps its deadline.
func (s *Service) processOrWait( func (s *Service) processOrWait(
ctx context.Context, req *ImageRequest, ctx context.Context, req *imagerequest.Request,
) (*ImageResponse, error) { ) (*ImageResponse, error) {
// A request that has already ended starts no processing // A request that has already ended starts no processing
if ctx.Err() != nil { if ctx.Err() != nil {
return nil, ctx.Err() return nil, ctx.Err()
} }
cacheKey := CacheKey(req) cacheKey := imagerequest.CacheKey(req)
// Closed when this request's own function runs, which singleflight does // Closed when this request's own function runs, which singleflight does
// only when no other request is processing the variant // only when no other request is processing the variant
@@ -293,7 +292,7 @@ func (s *Service) processOrWait(
recovered := recover() recovered := recover()
if recovered != nil { if recovered != nil {
s.log.Error("panic while processing image", s.log.Error("panic while processing image",
"host", req.SourceHost, "path", req.SourcePath, "host", req.Source.Host, "path", req.Source.Path,
"panic", recovered, "stack", string(debug.Stack())) "panic", recovered, "stack", string(debug.Stack()))
if hub := sentry.GetHubFromContext(ctx); hub != nil { if hub := sentry.GetHubFromContext(ctx); hub != nil {
@@ -393,8 +392,8 @@ func (s *Service) loadCachedSource(
// if available. // if available.
func (s *Service) processFromSourceOrFetch( func (s *Service) processFromSourceOrFetch(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *imagerequest.Request,
cacheKey VariantKey, cacheKey imagerequest.VariantKey,
) (*processedVariant, error) { ) (*processedVariant, error) {
// Check if we have cached source content // Check if we have cached source content
contentHash, _, err := s.cache.LookupSource(ctx, req) contentHash, _, err := s.cache.LookupSource(ctx, req)
@@ -431,8 +430,8 @@ func (s *Service) processFromSourceOrFetch(
// source URL is in the negative cache. // source URL is in the negative cache.
func (s *Service) fetchAndProcess( func (s *Service) fetchAndProcess(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *imagerequest.Request,
cacheKey VariantKey, cacheKey imagerequest.VariantKey,
) (*processedVariant, error) { ) (*processedVariant, error) {
// Check negative cache - skip fetching for recently-failed URLs // Check negative cache - skip fetching for recently-failed URLs
negHit, err := s.cache.checkNegativeCache(ctx, req) negHit, err := s.cache.checkNegativeCache(ctx, req)
@@ -442,8 +441,8 @@ func (s *Service) fetchAndProcess(
if negHit { if negHit {
s.log.Debug("negative cache hit", s.log.Debug("negative cache hit",
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
) )
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached) return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
@@ -493,8 +492,8 @@ func (s *Service) fetchAndProcess(
// Log upstream fetch details // Log upstream fetch details
s.log.Info("upstream fetched", s.log.Info("upstream fetched",
"request_id", middleware.GetReqID(ctx), "request_id", middleware.GetReqID(ctx),
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
"bytes", fetchBytes, "bytes", fetchBytes,
"fetch_ms", fetchResult.FetchDurationMs, "fetch_ms", fetchResult.FetchDurationMs,
"rate", downloadRate, "rate", downloadRate,
@@ -510,7 +509,7 @@ func (s *Service) fetchAndProcess(
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType) err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
if err != nil { if err != nil {
s.log.Warn("source refused", "request_id", middleware.GetReqID(ctx), s.log.Warn("source refused", "request_id", middleware.GetReqID(ctx),
"host", req.SourceHost, "path", req.SourcePath, "host", req.Source.Host, "path", req.Source.Path,
"content_type", fetchResult.ContentType, "error", err) "content_type", fetchResult.ContentType, "error", err)
s.storeNegative(ctx, req, httpStatusUnsupportedMediaType, err) s.storeNegative(ctx, req, httpStatusUnsupportedMediaType, err)
@@ -533,12 +532,12 @@ func (s *Service) fetchAndProcess(
// failed with err, so the source is not fetched again until the entry // failed with err, so the source is not fetched again until the entry
// expires. A failure to store the entry is only logged. // expires. A failure to store the entry is only logged.
func (s *Service) storeNegative( func (s *Service) storeNegative(
ctx context.Context, req *ImageRequest, statusCode int, err error, ctx context.Context, req *imagerequest.Request, statusCode int, err error,
) { ) {
storeErr := s.cache.StoreNegative(ctx, req, statusCode, err.Error()) storeErr := s.cache.StoreNegative(ctx, req, statusCode, err.Error())
if storeErr != nil { if storeErr != nil {
s.log.Warn("failed to store negative cache entry", s.log.Warn("failed to store negative cache entry",
"host", req.SourceHost, "path", req.SourcePath, "error", storeErr) "host", req.Source.Host, "path", req.Source.Path, "error", storeErr)
} }
} }
@@ -554,24 +553,17 @@ type processedVariant struct {
// result. // result.
func (s *Service) processAndStore( func (s *Service) processAndStore(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *imagerequest.Request,
cacheKey VariantKey, cacheKey imagerequest.VariantKey,
source io.Reader, source io.Reader,
fetchBytes int64, fetchBytes int64,
) (*processedVariant, error) { ) (*processedVariant, error) {
// Process the image // Process the image
processStart := time.Now() processStart := time.Now()
processReq := &imageprocessor.Request{
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
Format: imageprocessor.Format(req.Format),
Quality: req.Quality,
FitMode: imageprocessor.FitMode(req.FitMode),
}
// 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 := s.processor.Process(ctx, source, processReq) processResult, err := s.processor.Process(ctx, source, &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)
} }
@@ -604,10 +596,10 @@ func (s *Service) processAndStore(
s.log.Info("image converted", s.log.Info("image converted",
"request_id", middleware.GetReqID(ctx), "request_id", middleware.GetReqID(ctx),
"host", req.SourceHost, "host", req.Source.Host,
"path", req.SourcePath, "path", req.Source.Path,
"src_format", processResult.InputFormat, "src_format", processResult.InputFormat,
"dst_format", req.Format, "dst_format", req.Transform.Format,
"src_bytes", fetchBytes, "src_bytes", fetchBytes,
"dst_bytes", outputSize, "dst_bytes", outputSize,
"src_dimensions", fmt.Sprintf("%dx%d", "src_dimensions", fmt.Sprintf("%dx%d",
@@ -616,8 +608,8 @@ func (s *Service) processAndStore(
processResult.Width, processResult.Height), processResult.Width, processResult.Height),
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent), "size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
"convert_ms", processDuration.Milliseconds(), "convert_ms", processDuration.Milliseconds(),
"quality", req.Quality, "quality", req.Transform.Quality,
"fit", req.FitMode, "fit", req.Transform.FitMode,
) )
// Store variant to cache // Store variant to cache
@@ -636,24 +628,6 @@ func (s *Service) processAndStore(
}, nil }, nil
} }
// signatureRequest projects an ImageRequest onto the standalone
// signature.Request type used by the signature package. This keeps the
// import edge one-way: imgcache depends on signature, never the reverse.
func signatureRequest(req *ImageRequest) *signature.Request {
return &signature.Request{
SourceHost: req.SourceHost,
SourcePath: req.SourcePath,
SourceQuery: req.SourceQuery,
Width: req.Size.Width,
Height: req.Size.Height,
Format: string(req.Format),
Quality: req.Quality,
FitMode: string(req.FitMode),
Signature: req.Signature,
Expires: req.Expires,
}
}
// HTTP status codes for error responses. // HTTP status codes for error responses.
const ( const (
httpStatusBadGateway = 502 httpStatusBadGateway = 502
@@ -680,7 +654,7 @@ func extractStatusCode(err error) int {
const etagHashLength = 16 const etagHashLength = 16
// formatETag formats a VariantKey as a quoted ETag value. // formatETag formats a VariantKey as a quoted ETag value.
func formatETag(key VariantKey) string { func formatETag(key imagerequest.VariantKey) string {
hash := string(key) hash := string(key)
// Use first 16 characters of hash for a shorter but still unique ETag // Use first 16 characters of hash for a shorter but still unique ETag
if len(hash) > etagHashLength { if len(hash) > etagHashLength {
+218 -165
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -25,13 +26,17 @@ func TestService_Get_AllowlistedHost(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
resp, err := svc.Get(ctx, req) resp, err := svc.Get(ctx, req)
@@ -61,13 +66,17 @@ func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
svc, fixtures := SetupTestService(t, WithSigningKey("test-key")) svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.OtherHost, Source: imagerequest.Source{
SourcePath: testPathUpload, Host: fixtures.OtherHost,
Size: Size{Width: 50, Height: 50}, Path: testPathUpload,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Should fail validation - not allowlisted and no signature // Should fail validation - not allowlisted and no signature
@@ -84,19 +93,23 @@ func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.OtherHost, Source: imagerequest.Source{
SourcePath: testPathUpload, Host: fixtures.OtherHost,
Size: Size{Width: 50, Height: 50}, Path: testPathUpload,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Generate a valid signature // Generate a valid signature
signer := signature.New(signingKey) signer := signature.New(signingKey)
req.Expires = time.Now().Add(time.Hour) req.Expires = time.Now().Add(time.Hour)
req.Signature = signer.Sign(signatureRequest(req)) req.Signature = signer.Sign(req)
// Should pass validation // Should pass validation
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -128,19 +141,23 @@ func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
signingKey := testSigningKey signingKey := testSigningKey
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.OtherHost, Source: imagerequest.Source{
SourcePath: testPathUpload, Host: fixtures.OtherHost,
Size: Size{Width: 50, Height: 50}, Path: testPathUpload,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Generate an expired signature // Generate an expired signature
signer := signature.New(signingKey) signer := signature.New(signingKey)
req.Expires = time.Now().Add(-time.Hour) // Already expired req.Expires = time.Now().Add(-time.Hour) // Already expired
req.Signature = signer.Sign(signatureRequest(req)) req.Signature = signer.Sign(req)
// Should fail validation // Should fail validation
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -155,13 +172,17 @@ func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
signingKey := testSigningKey signingKey := testSigningKey
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.OtherHost, Source: imagerequest.Source{
SourcePath: testPathUpload, Host: fixtures.OtherHost,
Size: Size{Width: 50, Height: 50}, Path: testPathUpload,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// Set an invalid signature // Set an invalid signature
@@ -191,16 +212,20 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
signer := signature.New(signingKey) signer := signature.New(signingKey)
// Sign a request for "cdn.example.com" // Sign a request for "cdn.example.com"
signedReq := &ImageRequest{ signedReq := &imagerequest.Request{
SourceHost: testHostCDN, Source: imagerequest.Source{
SourcePath: testPathCat, Host: testHostCDN,
Size: Size{Width: 50, Height: 50}, Path: testPathCat,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Expires: time.Now().Add(time.Hour), Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
Expires: time.Now().Add(time.Hour),
} }
signedReq.Signature = signer.Sign(signatureRequest(signedReq)) signedReq.Signature = signer.Sign(signedReq)
// The original request should pass validation // The original request should pass validation
t.Run("exact host passes", func(t *testing.T) { t.Run("exact host passes", func(t *testing.T) {
@@ -228,23 +253,22 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
t.Run(tt.name+" rejected", func(t *testing.T) { t.Run(tt.name+" rejected", func(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: tt.host, Source: imagerequest.Source{
SourcePath: signedReq.SourcePath, Host: tt.host,
SourceQuery: signedReq.SourceQuery, Path: signedReq.Source.Path,
Size: signedReq.Size, Query: signedReq.Source.Query,
Format: signedReq.Format, },
Quality: signedReq.Quality, Transform: signedReq.Transform,
FitMode: signedReq.FitMode, Expires: signedReq.Expires,
Expires: signedReq.Expires, Signature: signedReq.Signature,
Signature: signedReq.Signature,
} }
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Errorf( t.Errorf(
"ValidateRequest() should reject signature for host %q (signed for %q)", "ValidateRequest() should reject signature for host %q (signed for %q)",
tt.host, signedReq.SourceHost) tt.host, signedReq.Source.Host)
} }
}) })
} }
@@ -280,13 +304,17 @@ func TestService_ValidateRequest_HostWithPortNeedsSignature(t *testing.T) {
t.Run(tt.host, func(t *testing.T) { t.Run(tt.host, func(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: tt.host, Source: imagerequest.Source{
SourcePath: testPathCat, Host: tt.host,
Size: Size{Width: 50, Height: 50}, Path: testPathCat,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -320,13 +348,17 @@ func TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature(
t.Run(host, func(t *testing.T) { t.Run(host, func(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: host, Source: imagerequest.Source{
SourcePath: testPathCat, Host: host,
Size: Size{Width: 50, Height: 50}, Path: testPathCat,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -347,14 +379,9 @@ func TestService_Get_InvalidFile(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := photoVariant(fixtures, 85, imagerequest.FitCover)
SourceHost: fixtures.GoodHost, req.Source.Path = "/images/fake.jpg"
SourcePath: "/images/fake.jpg", req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Should fail because magic bytes don't match // Should fail because magic bytes don't match
_, err := svc.Get(ctx, req) _, err := svc.Get(ctx, req)
@@ -369,14 +396,9 @@ func TestService_Get_NotFound(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := photoVariant(fixtures, 85, imagerequest.FitCover)
SourceHost: fixtures.GoodHost, req.Source.Path = "/images/nonexistent.jpg"
SourcePath: "/images/nonexistent.jpg", req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := svc.Get(ctx, req) _, err := svc.Get(ctx, req)
if err == nil { if err == nil {
@@ -393,25 +415,25 @@ func TestService_Get_FormatConversion(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
sourcePath string sourcePath string
outFormat ImageFormat outFormat imagerequest.Format
wantMIME string wantMIME string
}{ }{
{ {
name: "JPEG to PNG", name: "JPEG to PNG",
sourcePath: testPathPhoto, sourcePath: testPathPhoto,
outFormat: FormatPNG, outFormat: imagerequest.FormatPNG,
wantMIME: "image/png", wantMIME: "image/png",
}, },
{ {
name: "PNG to JPEG", name: "PNG to JPEG",
sourcePath: "/images/logo.png", sourcePath: "/images/logo.png",
outFormat: FormatJPEG, outFormat: imagerequest.FormatJPEG,
wantMIME: testContentTypeJPEG, wantMIME: testContentTypeJPEG,
}, },
{ {
name: "GIF to PNG", name: "GIF to PNG",
sourcePath: "/images/animation.gif", sourcePath: "/images/animation.gif",
outFormat: FormatPNG, outFormat: imagerequest.FormatPNG,
wantMIME: "image/png", wantMIME: "image/png",
}, },
} }
@@ -420,13 +442,12 @@ func TestService_Get_FormatConversion(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{Host: fixtures.GoodHost, Path: tt.sourcePath},
SourcePath: tt.sourcePath, Transform: imagerequest.Transform{
Size: Size{Width: 50, Height: 50}, Size: imagerequest.Size{Width: 50, Height: 50},
Format: tt.outFormat, Format: tt.outFormat, Quality: 85, FitMode: imagerequest.FitCover,
Quality: 85, },
FitMode: FitCover,
} }
resp, err := svc.Get(ctx, req) resp, err := svc.Get(ctx, req)
@@ -474,13 +495,17 @@ func TestService_Get_Caching(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// First request - should be a cache miss // First request - should be a cache miss
@@ -544,7 +569,7 @@ func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
t.Parallel() t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
variant := photoVariant(fixtures, 85, FitCover) variant := photoVariant(fixtures, 85, imagerequest.FitCover)
resp, err := svc.Get(t.Context(), variant) resp, err := svc.Get(t.Context(), variant)
if err != nil { if err != nil {
@@ -570,7 +595,7 @@ func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
} }
// Another quality is a new variant, made from the cached source // Another quality is a new variant, made from the cached source
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover)) resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
if err != nil { if err != nil {
t.Fatalf("Get() of a new variant error = %v", err) t.Fatalf("Get() of a new variant error = %v", err)
} }
@@ -586,20 +611,20 @@ func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
// backdateSource sets every time pixa stored for req's cached source to when, // backdateSource sets every time pixa stored for req's cached source to when,
// as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo. // as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
func backdateSource( func backdateSource(
t *testing.T, cache *Cache, req *ImageRequest, when time.Time, t *testing.T, cache *Cache, req *imagerequest.Request, when time.Time,
) { ) {
t.Helper() t.Helper()
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery) pathHash := HashPath(req.Source.Path + "?" + req.Source.Query)
meta, err := cache.srcMetadata.Load(req.SourceHost, pathHash) meta, err := cache.srcMetadata.Load(req.Source.Host, pathHash)
if err != nil { if err != nil {
t.Fatalf("reading the source's metadata file: %v", err) t.Fatalf("reading the source's metadata file: %v", err)
} }
meta.FetchedAt = when.Unix() meta.FetchedAt = when.Unix()
err = cache.srcMetadata.Store(req.SourceHost, pathHash, meta) err = cache.srcMetadata.Store(req.Source.Host, pathHash, meta)
if err != nil { if err != nil {
t.Fatalf("writing the source's metadata file: %v", err) t.Fatalf("writing the source's metadata file: %v", err)
} }
@@ -609,7 +634,7 @@ func backdateSource(
updateOneRow(t, cache, ` updateOneRow(t, cache, `
UPDATE source_metadata SET fetched_at = ? UPDATE source_metadata SET fetched_at = ?
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, dbTime, req.SourceHost, req.SourcePath, req.SourceQuery) `, dbTime, req.Source.Host, req.Source.Path, req.Source.Query)
updateOneRow(t, cache, updateOneRow(t, cache,
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`, `UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
dbTime, meta.ContentHash) dbTime, meta.ContentHash)
@@ -617,7 +642,7 @@ func backdateSource(
for _, path := range []string{ for _, path := range []string{
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)), cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
cache.srcMetadata.metaPath(req.SourceHost, pathHash), cache.srcMetadata.metaPath(req.Source.Host, pathHash),
} { } {
err = os.Chtimes(path, when, when) err = os.Chtimes(path, when, when)
if err != nil { if err != nil {
@@ -630,11 +655,11 @@ func backdateSource(
// for to when, as listed on // for to when, as listed on
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo. // TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
func backdateVariant( func backdateVariant(
t *testing.T, cache *Cache, req *ImageRequest, when time.Time, t *testing.T, cache *Cache, req *imagerequest.Request, when time.Time,
) { ) {
t.Helper() t.Helper()
key := CacheKey(req) key := imagerequest.CacheKey(req)
content, size, contentType, err := cache.variants.LoadWithMeta(key) content, size, contentType, err := cache.variants.LoadWithMeta(key)
if err != nil || contentType == "" { if err != nil || contentType == "" {
@@ -696,7 +721,7 @@ func TestService_Get_DifferentSizes(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Request same image at different sizes // Request same image at different sizes
sizes := []Size{ sizes := []imagerequest.Size{
{Width: 25, Height: 25}, {Width: 25, Height: 25},
{Width: 50, Height: 50}, {Width: 50, Height: 50},
{Width: 75, Height: 75}, {Width: 75, Height: 75},
@@ -705,13 +730,17 @@ func TestService_Get_DifferentSizes(t *testing.T) {
responses := make([][]byte, 0, len(sizes)) responses := make([][]byte, 0, len(sizes))
for _, size := range sizes { for _, size := range sizes {
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: size, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: size,
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
resp, err := svc.Get(ctx, req) resp, err := svc.Get(ctx, req)
@@ -745,13 +774,17 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
// Service with no signing key - all non-allowlisted requests should fail // Service with no signing key - all non-allowlisted requests should fail
svc, fixtures := SetupTestService(t, WithNoAllowlist()) svc, fixtures := SetupTestService(t, WithNoAllowlist())
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.OtherHost, Source: imagerequest.Source{
SourcePath: testPathUpload, Host: fixtures.OtherHost,
Size: Size{Width: 50, Height: 50}, Path: testPathUpload,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -770,13 +803,17 @@ func TestService_Get_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately cancel() // Cancel immediately
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
_, err := svc.Get(ctx, req) _, err := svc.Get(ctx, req)
@@ -791,13 +828,17 @@ func TestService_Get_ReturnsETag(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
resp, err := svc.Get(ctx, req) resp, err := svc.Get(ctx, req)
@@ -824,13 +865,17 @@ func TestService_Get_ETagConsistency(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// First request // First request
@@ -866,22 +911,30 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Request same image at different sizes - should get different ETags // Request same image at different sizes - should get different ETags
req1 := &ImageRequest{ req1 := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 25, Height: 25}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 25, Height: 25},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
req2 := &ImageRequest{ req2 := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
resp1, err := svc.Get(ctx, req1) resp1, err := svc.Get(ctx, req1)
+34 -21
View File
@@ -15,6 +15,7 @@ import (
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
func setupStatsTestDB(t *testing.T) *sql.DB { func setupStatsTestDB(t *testing.T) *sql.DB {
@@ -319,13 +320,17 @@ func TestService_Get_CountsStats(t *testing.T) {
} }
for _, step := range steps { for _, step := range steps {
resp, err := svc.Get(t.Context(), &ImageRequest{ resp, err := svc.Get(t.Context(), &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: step.path, Host: fixtures.GoodHost,
Size: Size{Width: step.size, Height: step.size}, Path: step.path,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: step.size, Height: step.size},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}) })
if (err != nil) != step.wantErr { if (err != nil) != step.wantErr {
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr) t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
@@ -419,13 +424,17 @@ func TestService_Get_CountsInterruptedMisses(t *testing.T) {
svc.fetcher = upstream svc.fetcher = upstream
resp, err := svc.Get(ctx, &ImageRequest{ resp, err := svc.Get(ctx, &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}) })
t.Logf("Get() error = %v", err) t.Logf("Get() error = %v", err)
@@ -453,13 +462,17 @@ func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
req := &ImageRequest{ req := &imagerequest.Request{
SourceHost: fixtures.GoodHost, Source: imagerequest.Source{
SourcePath: testPathPhoto, Host: fixtures.GoodHost,
Size: Size{Width: 50, Height: 50}, Path: testPathPhoto,
Format: FormatJPEG, },
Quality: 85, Transform: imagerequest.Transform{
FitMode: FitCover, Size: imagerequest.Size{Width: 50, Height: 50},
Format: imagerequest.FormatJPEG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
} }
// A first request caches the variant. // A first request caches the variant.
+11 -39
View File
@@ -10,8 +10,9 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// Storage constants. // Storage constants.
@@ -32,9 +33,6 @@ var (
// ContentHash is a SHA256 hash of file content (hex-encoded). // ContentHash is a SHA256 hash of file content (hex-encoded).
type ContentHash string type ContentHash string
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
type VariantKey string
// PathHash is a SHA256 hash of a URL path (hex-encoded). // PathHash is a SHA256 hash of a URL path (hex-encoded).
type PathHash string type PathHash string
@@ -403,34 +401,6 @@ func HashPath(path string) PathHash {
return PathHash(hex.EncodeToString(h[:])) return PathHash(hex.EncodeToString(h[:]))
} }
// CacheKey generates a unique key for a request variant.
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
// The path and query go through escapePercentAndColon, so that a ":" in
// either cannot be taken for the one between them.
func CacheKey(req *ImageRequest) VariantKey {
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
req.SourceHost,
escapePercentAndColon(req.SourcePath),
escapePercentAndColon(req.SourceQuery),
req.Size.Width,
req.Size.Height,
req.Format,
req.Quality,
req.FitMode,
)
h := sha256.Sum256([]byte(data))
return VariantKey(hex.EncodeToString(h[:]))
}
// escapePercentAndColon returns s with each "%" written as "%25" and each
// ":" as "%3A", so that the result holds no ":".
func escapePercentAndColon(s string) string {
s = strings.ReplaceAll(s, "%", "%25")
return strings.ReplaceAll(s, ":", "%3A")
}
// VariantStorage handles key-based file storage for processed image variants. // VariantStorage handles key-based file storage for processed image variants.
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key> // Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta // Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
@@ -464,7 +434,7 @@ func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error
// with writeFileSynced. The .meta file is written first, so that a crash // with writeFileSynced. The .meta file is written first, so that a crash
// cannot leave the content in place without it. // cannot leave the content in place without it.
func (s *VariantStorage) Store( func (s *VariantStorage) Store(
key VariantKey, r io.Reader, contentType string, key imagerequest.VariantKey, r io.Reader, contentType string,
) (int64, error) { ) (int64, error) {
data, err := io.ReadAll(r) data, err := io.ReadAll(r)
if err != nil { if err != nil {
@@ -508,7 +478,7 @@ func (s *VariantStorage) Store(
} }
// Load returns a reader for the content at the given key. // Load returns a reader for the content at the given key.
func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) { func (s *VariantStorage) Load(key imagerequest.VariantKey) (io.ReadCloser, error) {
path := s.keyToPath(key) path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from cache key f, err := os.Open(path) //nolint:gosec // path derived from cache key
@@ -525,7 +495,9 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
// LoadWithSize returns a reader and file size for the content at the // LoadWithSize returns a reader and file size for the content at the
// given key. // given key.
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) { func (s *VariantStorage) LoadWithSize(
key imagerequest.VariantKey,
) (io.ReadCloser, int64, error) {
path := s.keyToPath(key) path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from cache key f, err := os.Open(path) //nolint:gosec // path derived from cache key
@@ -551,7 +523,7 @@ func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, err
// the given key. The content type is read from the .meta file, and is // the given key. The content type is read from the .meta file, and is
// empty when that file is missing or unreadable. // empty when that file is missing or unreadable.
func (s *VariantStorage) LoadWithMeta( func (s *VariantStorage) LoadWithMeta(
key VariantKey, key imagerequest.VariantKey,
) (io.ReadCloser, int64, string, error) { ) (io.ReadCloser, int64, string, error) {
f, size, err := s.LoadWithSize(key) f, size, err := s.LoadWithSize(key)
if err != nil { if err != nil {
@@ -574,7 +546,7 @@ func (s *VariantStorage) LoadWithMeta(
} }
// Exists checks if content exists at the given key. // Exists checks if content exists at the given key.
func (s *VariantStorage) Exists(key VariantKey) bool { func (s *VariantStorage) Exists(key imagerequest.VariantKey) bool {
path := s.keyToPath(key) path := s.keyToPath(key)
_, err := os.Stat(path) _, err := os.Stat(path)
@@ -583,7 +555,7 @@ func (s *VariantStorage) Exists(key VariantKey) bool {
// Delete removes the content at the given key together with its .meta // Delete removes the content at the given key together with its .meta
// sidecar file. A missing file is not an error. // sidecar file. A missing file is not an error.
func (s *VariantStorage) Delete(key VariantKey) error { func (s *VariantStorage) Delete(key imagerequest.VariantKey) error {
path := s.keyToPath(key) path := s.keyToPath(key)
err := os.Remove(path) err := os.Remove(path)
@@ -602,7 +574,7 @@ func (s *VariantStorage) Delete(key VariantKey) error {
} }
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key> // keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
func (s *VariantStorage) keyToPath(key VariantKey) string { func (s *VariantStorage) keyToPath(key imagerequest.VariantKey) string {
k := string(key) k := string(key)
if len(k) < MinHashLength { if len(k) < MinHashLength {
return filepath.Join(s.baseDir, k) return filepath.Join(s.baseDir, k)
+9 -144
View File
@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
func TestContentStorage_StoreAndLoad(t *testing.T) { func TestContentStorage_StoreAndLoad(t *testing.T) {
@@ -322,147 +324,6 @@ func TestHashPath(t *testing.T) {
} }
} }
func TestCacheKey(t *testing.T) {
t.Parallel()
req1 := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
req2 := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
// Same request should produce same key
key1 := CacheKey(req1)
key2 := CacheKey(req2)
if key1 != key2 {
t.Errorf("CacheKey() not deterministic: %s vs %s", key1, key2)
}
// Key should be 64 hex chars
if len(string(key1)) != 64 {
t.Errorf("CacheKey() length = %d, want 64", len(string(key1)))
}
// Different size should produce different key
req3 := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
SourceQuery: "",
Size: Size{Width: 400, Height: 300}, // Different size
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
key3 := CacheKey(req3)
if key1 == key3 {
t.Error("CacheKey() produced same key for different sizes")
}
// Different format should produce different key
req4 := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatPNG, // Different format
Quality: 85,
FitMode: FitCover,
}
key4 := CacheKey(req4)
if key1 == key4 {
t.Error("CacheKey() produced same key for different formats")
}
// Different quality should produce different key
req5 := &ImageRequest{
SourceHost: testHostCDN,
SourcePath: testPathCat,
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 50, // Different quality
FitMode: FitCover,
}
key5 := CacheKey(req5)
if key1 == key5 {
t.Error("CacheKey() produced same key for different quality")
}
}
// TestCacheKey_ColonOrPercentInPathOrQuery checks that two different source
// URLs whose path or query holds ":" or "%" get different keys.
func TestCacheKey_ColonOrPercentInPathOrQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path1, query1 string
path2, query2 string
}{
{
name: "colon moved from path to query",
path1: "/a:b", query1: "",
path2: "/a", query2: "b:",
},
{
name: "colon in path and its percent-encoding",
path1: "/a:b", query1: "",
path2: "/a%3Ab", query2: "",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
key1 := CacheKey(&ImageRequest{
SourceHost: testHostCDN,
SourcePath: tt.path1,
SourceQuery: tt.query1,
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
})
key2 := CacheKey(&ImageRequest{
SourceHost: testHostCDN,
SourcePath: tt.path2,
SourceQuery: tt.query2,
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
})
t.Logf("CacheKey() of path %q query %q = %s", tt.path1, tt.query1, key1)
t.Logf("CacheKey() of path %q query %q = %s", tt.path2, tt.query2, key2)
if key1 == key2 {
t.Errorf("CacheKey() gave path %q query %q and path %q query %q "+
"the same key", tt.path1, tt.query1, tt.path2, tt.query2)
}
})
}
}
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write // TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
// that fails is logged at warn and the store still succeeds. // that fails is logged at warn and the store still succeeds.
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) { func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
@@ -476,7 +337,9 @@ func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
t.Fatalf("NewVariantStorage() error = %v", err) t.Fatalf("NewVariantStorage() error = %v", err)
} }
key := CacheKey(&ImageRequest{SourceHost: testHostCDN, SourcePath: testPathCat}) key := imagerequest.CacheKey(&imagerequest.Request{
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
})
// A directory where the .meta file goes makes the .meta write fail. // A directory where the .meta file goes makes the .meta write fail.
err = os.MkdirAll(storage.keyToPath(key)+".meta", StorageDirPerm) err = os.MkdirAll(storage.keyToPath(key)+".meta", StorageDirPerm)
@@ -497,7 +360,7 @@ func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
// storeTestVariant stores one variant, with its .meta file, in a new // storeTestVariant stores one variant, with its .meta file, in a new
// VariantStorage and returns the storage and the variant's key. // VariantStorage and returns the storage and the variant's key.
func storeTestVariant(t *testing.T) (*VariantStorage, VariantKey) { func storeTestVariant(t *testing.T) (*VariantStorage, imagerequest.VariantKey) {
t.Helper() t.Helper()
storage, err := NewVariantStorage(t.TempDir(), slog.New(slog.DiscardHandler)) storage, err := NewVariantStorage(t.TempDir(), slog.New(slog.DiscardHandler))
@@ -505,7 +368,9 @@ func storeTestVariant(t *testing.T) (*VariantStorage, VariantKey) {
t.Fatalf("NewVariantStorage() error = %v", err) t.Fatalf("NewVariantStorage() error = %v", err)
} }
key := CacheKey(&ImageRequest{SourceHost: testHostCDN, SourcePath: testPathCat}) key := imagerequest.CacheKey(&imagerequest.Request{
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
})
_, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp") _, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp")
if err != nil { if err != nil {
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProxyRefusesComputedSideAboveMaximum requests sizes of a 3000x600 // TestImageProxyRefusesComputedSideAboveMaximum requests sizes of a 3000x600
@@ -70,9 +70,12 @@ func TestImageProxyRefusesComputedSideAboveMaximum(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
token, err := generator.Generate(&encurl.Payload{ token, err := generator.Generate(&imagerequest.Request{
SourceHost: upstreamHost, SourcePath: tt.path, Source: imagerequest.Source{Host: upstreamHost, Path: tt.path},
Width: tt.width, Height: tt.height, Format: imgcache.FormatJPEG, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
Format: imagerequest.FormatJPEG,
},
}) })
if err != nil { if err != nil {
t.Fatalf("encurl Generate: %v", err) t.Fatalf("encurl Generate: %v", err)
+8 -5
View File
@@ -7,7 +7,7 @@ import (
"testing" "testing"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// TestImageProxyRefusesFitOutsideAboveMaximum requests sizes with fit=outside // TestImageProxyRefusesFitOutsideAboveMaximum requests sizes with fit=outside
@@ -59,10 +59,13 @@ func TestImageProxyRefusesFitOutsideAboveMaximum(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
token, err := generator.Generate(&encurl.Payload{ token, err := generator.Generate(&imagerequest.Request{
SourceHost: upstreamHost, SourcePath: tt.path, Source: imagerequest.Source{Host: upstreamHost, Path: tt.path},
Width: tt.width, Height: tt.height, Format: imgcache.FormatJPEG, Transform: imagerequest.Transform{
FitMode: imgcache.FitOutside, Size: imagerequest.Size{Width: tt.width, Height: tt.height},
Format: imagerequest.FormatJPEG,
FitMode: imagerequest.FitOutside,
},
}) })
if err != nil { if err != nil {
t.Fatalf("encurl Generate: %v", err) t.Fatalf("encurl Generate: %v", err)
+14 -9
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/session" "sneak.berlin/go/pixa/internal/session"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
@@ -513,12 +514,16 @@ func signedImageURL(t *testing.T, sourceHost string) string {
t.Helper() t.Helper()
path, sig, exp := signature.New(testSigningKey).GenerateSignedURL( path, sig, exp := signature.New(testSigningKey).GenerateSignedURL(
&signature.Request{ &imagerequest.Request{
SourceHost: sourceHost, Source: imagerequest.Source{
SourcePath: ipv6SourcePath, Host: sourceHost,
Format: string(imgcache.FormatPNG), Path: ipv6SourcePath,
Quality: 85, },
FitMode: string(imgcache.FitCover), Transform: imagerequest.Transform{
Format: imagerequest.FormatPNG,
Quality: 85,
FitMode: imagerequest.FitCover,
},
}, time.Hour) }, time.Hour)
return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(exp, 10) return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(exp, 10)
@@ -534,9 +539,9 @@ func encryptedImageURL(t *testing.T, sourceHost string) string {
t.Fatalf("encurl.NewGenerator: %v", err) t.Fatalf("encurl.NewGenerator: %v", err)
} }
token, err := generator.Generate(&encurl.Payload{ token, err := generator.Generate(&imagerequest.Request{
SourceHost: sourceHost, SourcePath: ipv6SourcePath, Source: imagerequest.Source{Host: sourceHost, Path: ipv6SourcePath},
Format: imgcache.FormatPNG, Transform: imagerequest.Transform{Format: imagerequest.FormatPNG},
}) })
if err != nil { if err != nil {
t.Fatalf("encurl Generate: %v", err) t.Fatalf("encurl Generate: %v", err)
+10 -7
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// svgServedAsAVIF is an SVG whose first bytes pass the first-bytes check for // svgServedAsAVIF is an SVG whose first bytes pass the first-bytes check for
@@ -38,10 +38,13 @@ func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
t.Fatalf("encurl.NewGenerator: %v", err) t.Fatalf("encurl.NewGenerator: %v", err)
} }
encryptedURL := func(format imgcache.ImageFormat) string { encryptedURL := func(format imagerequest.Format) string {
token, err := generator.Generate(&encurl.Payload{ token, err := generator.Generate(&imagerequest.Request{
SourceHost: upstreamHost, SourcePath: "/drawing.avif", Source: imagerequest.Source{Host: upstreamHost, Path: "/drawing.avif"},
Width: 32, Height: 24, Format: format, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 32, Height: 24},
Format: format,
},
}) })
if err != nil { if err != nil {
t.Fatalf("encurl Generate: %v", err) t.Fatalf("encurl Generate: %v", err)
@@ -53,8 +56,8 @@ func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
for _, target := range []string{ for _, target := range []string{
"/v1/image/" + upstreamHost + "/drawing.avif/32x24.png", "/v1/image/" + upstreamHost + "/drawing.avif/32x24.png",
"/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig", "/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig",
encryptedURL(imgcache.FormatPNG), encryptedURL(imagerequest.FormatPNG),
encryptedURL(imgcache.FormatOriginal), encryptedURL(imagerequest.FormatOriginal),
} { } {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequestWithContext( s.ServeHTTP(rec, httptest.NewRequestWithContext(
+31 -24
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -16,32 +17,35 @@ func TestSigner_Verify_ColonCannotMovePathQueryBoundary(t *testing.T) {
signer := signature.New(goldenSigningKey) signer := signature.New(goldenSigningKey)
signed := &signature.Request{ signed := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: "/a:b", Host: testHost,
Width: 800, Path: "/a:b",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitCover, Format: testFormatWebP,
Expires: time.Now().Add(1 * time.Hour), Quality: 85,
FitMode: testFitCover,
},
Expires: time.Now().Add(1 * time.Hour),
} }
signed.Signature = signer.Sign(signed) signed.Signature = signer.Sign(signed)
replayed := *signed replayed := *signed
replayed.SourcePath = "/a" replayed.Source.Path = "/a"
replayed.SourceQuery = "b:" replayed.Source.Query = "b:"
t.Logf("signature for path %q query %q: %s", t.Logf("signature for path %q query %q: %s",
signed.SourcePath, signed.SourceQuery, signed.Signature) signed.Source.Path, signed.Source.Query, signed.Signature)
t.Logf("signature for path %q query %q: %s", t.Logf("signature for path %q query %q: %s",
replayed.SourcePath, replayed.SourceQuery, signer.Sign(&replayed)) replayed.Source.Path, replayed.Source.Query, signer.Sign(&replayed))
err := signer.Verify(&replayed) err := signer.Verify(&replayed)
if !errors.Is(err, signature.ErrInvalid) { if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() of path %q query %q with the signature for "+ t.Errorf("Verify() of path %q query %q with the signature for "+
"path %q = %v, want %v", "path %q = %v, want %v",
replayed.SourcePath, replayed.SourceQuery, signed.SourcePath, replayed.Source.Path, replayed.Source.Query, signed.Source.Path,
err, signature.ErrInvalid) err, signature.ErrInvalid)
} }
} }
@@ -90,16 +94,19 @@ func TestSigner_EscapedGoldenVectors(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: tt.path, Host: testHost,
SourceQuery: tt.query, Path: tt.path,
Width: 800, Query: tt.query,
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitCover, Format: testFormatWebP,
Expires: time.Unix(goldenExpiresUnix, 0), Quality: 85,
FitMode: testFitCover,
},
Expires: time.Unix(goldenExpiresUnix, 0),
} }
gotSignature := signer.Sign(req) gotSignature := signer.Sign(req)
+50 -37
View File
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -16,7 +17,7 @@ const goldenSigningKey = "golden-test-key"
type goldenVector struct { type goldenVector struct {
name string name string
req signature.Request req imagerequest.Request
// wantSignature is the exact base64url (RFC 4648 URL-safe, // wantSignature is the exact base64url (RFC 4648 URL-safe,
// padded) HMAC-SHA256 signature for the request with Expires // padded) HMAC-SHA256 signature for the request with Expires
// set to goldenExpiresUnix. // set to goldenExpiresUnix.
@@ -34,15 +35,18 @@ func goldenVectors() []goldenVector {
return []goldenVector{ return []goldenVector{
{ {
name: "resized without query", name: "resized without query",
req: signature.Request{ req: imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "", Path: testPath,
Width: 800, Query: "",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitCover, Format: testFormatWebP,
Quality: 85,
FitMode: testFitCover,
},
}, },
// Signed data: // Signed data:
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover" // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
@@ -51,15 +55,18 @@ func goldenVectors() []goldenVector {
}, },
{ {
name: "resized with query string", name: "resized with query string",
req: signature.Request{ req: imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "token=abc&v=2", Path: testPath,
Width: 800, Query: "token=abc&v=2",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitCover, Format: testFormatWebP,
Quality: 85,
FitMode: testFitCover,
},
}, },
// Signed data: // Signed data:
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover" // "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
@@ -69,15 +76,18 @@ func goldenVectors() []goldenVector {
}, },
{ {
name: "original size without query", name: "original size without query",
req: signature.Request{ req: imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "", Path: testPath,
Width: 0, Query: "",
Height: 0, },
Format: testFormatPNG, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 0, Height: 0},
FitMode: testFitCover, Format: testFormatPNG,
Quality: 85,
FitMode: testFitCover,
},
}, },
// Signed data: // Signed data:
// "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover" // "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover"
@@ -86,15 +96,18 @@ func goldenVectors() []goldenVector {
}, },
{ {
name: "non-default quality and fit", name: "non-default quality and fit",
req: signature.Request{ req: imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "", Path: testPath,
Width: 800, Query: "",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 40, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitContain, Format: testFormatWebP,
Quality: 40,
FitMode: testFitContain,
},
}, },
// Signed data: // Signed data:
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain" // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain"
+17 -13
View File
@@ -5,22 +5,26 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
// signedQualityFitRequest returns a request signed for quality 85 and fit // signedQualityFitRequest returns a request signed for quality 85 and fit
// mode "cover", the effective defaults the handler applies before // mode "cover", the effective defaults the handler applies before
// verification. // verification.
func signedQualityFitRequest(signer *signature.Signer) *signature.Request { func signedQualityFitRequest(signer *signature.Signer) *imagerequest.Request {
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
Width: 800, Path: testPath,
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitCover, Format: testFormatWebP,
Expires: time.Now().Add(1 * time.Hour), Quality: 85,
FitMode: testFitCover,
},
Expires: time.Now().Add(1 * time.Hour),
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -40,15 +44,15 @@ func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
tamper func(r *signature.Request) tamper func(r *imagerequest.Request)
}{ }{
{ {
name: "replayed with different quality", name: "replayed with different quality",
tamper: func(r *signature.Request) { r.Quality = 40 }, tamper: func(r *imagerequest.Request) { r.Transform.Quality = 40 },
}, },
{ {
name: "replayed with different fit mode", name: "replayed with different fit mode",
tamper: func(r *signature.Request) { r.FitMode = testFitContain }, tamper: func(r *imagerequest.Request) { r.Transform.FitMode = testFitContain },
}, },
} }
+25 -50
View File
@@ -12,6 +12,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
) )
// Signature errors. // Signature errors.
@@ -22,37 +24,6 @@ var (
ErrMissingExpiration = errors.New("signature expiration is required") ErrMissingExpiration = errors.New("signature expiration is required")
) )
// Request carries the components an image request signature covers. It is a
// standalone type so that this package does not depend on imgcache, keeping
// the import edge one-way (imgcache depends on signature, never the reverse).
type Request struct {
// SourceHost is the origin host (e.g. "cdn.example.com").
SourceHost string
// SourcePath is the path on the origin (e.g. "/photos/cat.jpg").
SourcePath string
// SourceQuery is the optional query string for the origin URL.
SourceQuery string
// Width is the requested output width in pixels.
Width int
// Height is the requested output height in pixels.
Height int
// Format is the requested output format (e.g. "webp").
Format string
// Quality is the requested output quality (1-100) for lossy formats.
// It is the effective value the request resolves to: callers pass the
// default quality when the request omits the parameter, so an omitted
// quality signs identically to that same value stated explicitly.
Quality int
// FitMode is how the image is fit into the requested dimensions
// (e.g. "cover"). Like Quality it is the effective value: callers pass
// the default fit mode when the request omits the parameter.
FitMode string
// Signature is the HMAC signature to verify.
Signature string
// Expires is the signature expiration timestamp.
Expires time.Time
}
// Signer handles HMAC-SHA256 signature generation and verification. // Signer handles HMAC-SHA256 signature generation and verification.
type Signer struct { type Signer struct {
secretKey []byte secretKey []byte
@@ -67,8 +38,11 @@ func New(secretKey string) *Signer {
// Sign generates an HMAC-SHA256 signature for the given request. // Sign generates an HMAC-SHA256 signature for the given request.
// The signature covers: host + path + query + width + height + format + // The signature covers: host + path + query + width + height + format +
// expiration + quality + fit. // expiration + quality + fit. The quality and fit mode are the effective
func (s *Signer) Sign(req *Request) string { // values the request resolves to: callers pass the defaults when the request
// omits those parameters, so an omitted quality or fit mode signs
// identically to that same value stated explicitly.
func (s *Signer) Sign(req *imagerequest.Request) string {
data := s.buildSignatureData(req) data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey) mac := hmac.New(sha256.New, s.secretKey)
mac.Write([]byte(data)) mac.Write([]byte(data))
@@ -84,7 +58,7 @@ func (s *Signer) Sign(req *Request) string {
// No suffix matching, wildcard matching, or partial matching is supported. // No suffix matching, wildcard matching, or partial matching is supported.
// A signature for "cdn.example.com" will NOT verify for "example.com" or // A signature for "cdn.example.com" will NOT verify for "example.com" or
// "other.cdn.example.com", and vice versa. // "other.cdn.example.com", and vice versa.
func (s *Signer) Verify(req *Request) error { func (s *Signer) Verify(req *imagerequest.Request) error {
// Check expiration first // Check expiration first
if req.Expires.IsZero() { if req.Expires.IsZero() {
return ErrMissingExpiration return ErrMissingExpiration
@@ -108,7 +82,7 @@ func (s *Signer) Verify(req *Request) error {
// GenerateSignedURL creates a complete URL with signature and expiration. // GenerateSignedURL creates a complete URL with signature and expiration.
// Returns the path portion that should be appended to the base URL. // Returns the path portion that should be appended to the base URL.
func (s *Signer) GenerateSignedURL( func (s *Signer) GenerateSignedURL(
req *Request, ttl time.Duration, req *imagerequest.Request, ttl time.Duration,
) (string, string, int64) { ) (string, string, int64) {
// Set expiration // Set expiration
req.Expires = time.Now().Add(ttl) req.Expires = time.Now().Add(ttl)
@@ -120,10 +94,11 @@ func (s *Signer) GenerateSignedURL(
// Build the size component // Build the size component
var sizeStr string var sizeStr string
if req.Width == 0 && req.Height == 0 { if req.Transform.Size.Width == 0 && req.Transform.Size.Height == 0 {
sizeStr = "orig" sizeStr = "orig"
} else { } else {
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height) sizeStr = fmt.Sprintf("%dx%d",
req.Transform.Size.Width, req.Transform.Size.Height)
} }
// Build the path. A source query follows the source path after a "?". // Build the path. A source query follows the source path after a "?".
@@ -131,13 +106,13 @@ func (s *Signer) GenerateSignedURL(
// written in Go's own percent-encoding of a URL path, which writes "?" // written in Go's own percent-encoding of a URL path, which writes "?"
// as %3F and "%" as %25, and leaves "/", ";", ",", "=" and "&" as they // as %3F and "%" as %25, and leaves "/", ";", ",", "=" and "&" as they
// are. // are.
sourcePathAndQuery := req.SourcePath sourcePathAndQuery := req.Source.Path
if req.SourceQuery != "" { if req.Source.Query != "" {
sourcePathAndQuery += "?" + req.SourceQuery sourcePathAndQuery += "?" + req.Source.Query
} }
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%s.%s", unescapedPath := fmt.Sprintf("/v1/image/%s%s/%s.%s",
req.SourceHost, sourcePathAndQuery, sizeStr, req.Format) req.Source.Host, sourcePathAndQuery, sizeStr, req.Transform.Format)
return (&url.URL{Path: unescapedPath}).EscapedPath(), sig, exp return (&url.URL{Path: unescapedPath}).EscapedPath(), sig, exp
} }
@@ -150,17 +125,17 @@ func (s *Signer) GenerateSignedURL(
// expansion is performed. Quality and fit are the effective transform // expansion is performed. Quality and fit are the effective transform
// values, so replaying a signed URL with a different quality or fit mode // values, so replaying a signed URL with a different quality or fit mode
// fails verification. // fails verification.
func (s *Signer) buildSignatureData(req *Request) string { func (s *Signer) buildSignatureData(req *imagerequest.Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s", return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
req.SourceHost, req.Source.Host,
escapePercentAndColon(req.SourcePath), escapePercentAndColon(req.Source.Path),
escapePercentAndColon(req.SourceQuery), escapePercentAndColon(req.Source.Query),
req.Width, req.Transform.Size.Width,
req.Height, req.Transform.Size.Height,
req.Format, req.Transform.Format,
req.Expires.Unix(), req.Expires.Unix(),
req.Quality, req.Transform.Quality,
req.FitMode, req.Transform.FitMode,
) )
} }
+140 -106
View File
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
@@ -26,14 +27,17 @@ func TestSigner_Sign(t *testing.T) {
signer := signature.New("test-secret-key") signer := signature.New("test-secret-key")
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "", Path: testPath,
Width: 800, Query: "",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
} }
sig1 := signer.Sign(req) sig1 := signer.Sign(req)
@@ -51,14 +55,17 @@ func TestSigner_Sign(t *testing.T) {
} }
// Different input should produce different signature // Different input should produce different signature
req2 := &signature.Request{ req2 := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: "/photos/dog.jpg", // Different path Host: testHost,
SourceQuery: "", Path: "/photos/dog.jpg", // Different path
Width: 800, Query: "",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Expires: time.Unix(1704067200, 0), Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
Expires: time.Unix(1704067200, 0),
} }
sig3 := signer.Sign(req2) sig3 := signer.Sign(req2)
@@ -69,20 +76,23 @@ func TestSigner_Sign(t *testing.T) {
// validVerifyRequest returns a fully-populated request that verifies // validVerifyRequest returns a fully-populated request that verifies
// successfully once signed. // successfully once signed.
func validVerifyRequest() *signature.Request { func validVerifyRequest() *imagerequest.Request {
return &signature.Request{ return &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
Width: 800, Path: testPath,
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Expires: time.Now().Add(1 * time.Hour), Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
Expires: time.Now().Add(1 * time.Hour),
} }
} }
type verifyCase struct { type verifyCase struct {
name string name string
setup func() *signature.Request setup func() *imagerequest.Request
wantErr error wantErr error
} }
@@ -90,7 +100,7 @@ func verifyCases(signer *signature.Signer) []verifyCase {
return []verifyCase{ return []verifyCase{
{ {
name: "valid signature", name: "valid signature",
setup: func() *signature.Request { setup: func() *imagerequest.Request {
req := validVerifyRequest() req := validVerifyRequest()
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -100,7 +110,7 @@ func verifyCases(signer *signature.Signer) []verifyCase {
}, },
{ {
name: "expired signature", name: "expired signature",
setup: func() *signature.Request { setup: func() *imagerequest.Request {
req := validVerifyRequest() req := validVerifyRequest()
req.Expires = time.Now().Add(-1 * time.Hour) req.Expires = time.Now().Add(-1 * time.Hour)
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -111,7 +121,7 @@ func verifyCases(signer *signature.Signer) []verifyCase {
}, },
{ {
name: "invalid signature", name: "invalid signature",
setup: func() *signature.Request { setup: func() *imagerequest.Request {
req := validVerifyRequest() req := validVerifyRequest()
req.Signature = "invalid-signature" req.Signature = "invalid-signature"
@@ -121,7 +131,7 @@ func verifyCases(signer *signature.Signer) []verifyCase {
}, },
{ {
name: "missing expiration", name: "missing expiration",
setup: func() *signature.Request { setup: func() *imagerequest.Request {
req := validVerifyRequest() req := validVerifyRequest()
req.Expires = time.Time{} req.Expires = time.Time{}
req.Signature = "some-signature" req.Signature = "some-signature"
@@ -132,10 +142,10 @@ func verifyCases(signer *signature.Signer) []verifyCase {
}, },
{ {
name: "tampered request", name: "tampered request",
setup: func() *signature.Request { setup: func() *imagerequest.Request {
req := validVerifyRequest() req := validVerifyRequest()
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
req.SourcePath = "/photos/secret.jpg" req.Source.Path = "/photos/secret.jpg"
return req return req
}, },
@@ -173,7 +183,7 @@ func TestSigner_Verify(t *testing.T) {
type tamperCase struct { type tamperCase struct {
name string name string
tamper func(r *signature.Request) tamper func(r *imagerequest.Request)
} }
// exactMatchTamperCases mutates one signed component per case; every // exactMatchTamperCases mutates one signed component per case; every
@@ -182,59 +192,59 @@ func exactMatchTamperCases() []tamperCase {
return []tamperCase{ return []tamperCase{
{ {
name: "parent domain does not match subdomain", name: "parent domain does not match subdomain",
tamper: func(r *signature.Request) { r.SourceHost = "example.com" }, tamper: func(r *imagerequest.Request) { r.Source.Host = "example.com" },
}, },
{ {
name: "subdomain does not match parent domain", name: "subdomain does not match parent domain",
tamper: func(r *signature.Request) { r.SourceHost = "images.cdn.example.com" }, tamper: func(r *imagerequest.Request) { r.Source.Host = "images.cdn.example.com" },
}, },
{ {
name: "sibling subdomain does not match", name: "sibling subdomain does not match",
tamper: func(r *signature.Request) { r.SourceHost = "images.example.com" }, tamper: func(r *imagerequest.Request) { r.Source.Host = "images.example.com" },
}, },
{ {
name: "host with suffix appended does not match", name: "host with suffix appended does not match",
tamper: func(r *signature.Request) { r.SourceHost = testHost + ".evil.com" }, tamper: func(r *imagerequest.Request) { r.Source.Host = testHost + ".evil.com" },
}, },
{ {
name: "host with prefix does not match", name: "host with prefix does not match",
tamper: func(r *signature.Request) { r.SourceHost = "evilcdn.example.com" }, tamper: func(r *imagerequest.Request) { r.Source.Host = "evilcdn.example.com" },
}, },
{ {
name: "different path does not match", name: "different path does not match",
tamper: func(r *signature.Request) { r.SourcePath = "/photos/dog.jpg" }, tamper: func(r *imagerequest.Request) { r.Source.Path = "/photos/dog.jpg" },
}, },
{ {
name: "path suffix does not match", name: "path suffix does not match",
tamper: func(r *signature.Request) { r.SourcePath = testPath + "/extra" }, tamper: func(r *imagerequest.Request) { r.Source.Path = testPath + "/extra" },
}, },
{ {
name: "path prefix does not match", name: "path prefix does not match",
tamper: func(r *signature.Request) { r.SourcePath = "/other" + testPath }, tamper: func(r *imagerequest.Request) { r.Source.Path = "/other" + testPath },
}, },
{ {
name: "different query does not match", name: "different query does not match",
tamper: func(r *signature.Request) { r.SourceQuery = "token=xyz" }, tamper: func(r *imagerequest.Request) { r.Source.Query = "token=xyz" },
}, },
{ {
name: "added query does not match empty query", name: "added query does not match empty query",
tamper: func(r *signature.Request) { r.SourceQuery = "extra=1" }, tamper: func(r *imagerequest.Request) { r.Source.Query = "extra=1" },
}, },
{ {
name: "removed query does not match", name: "removed query does not match",
tamper: func(r *signature.Request) { r.SourceQuery = "" }, tamper: func(r *imagerequest.Request) { r.Source.Query = "" },
}, },
{ {
name: "different width does not match", name: "different width does not match",
tamper: func(r *signature.Request) { r.Width = 801 }, tamper: func(r *imagerequest.Request) { r.Transform.Size.Width = 801 },
}, },
{ {
name: "different height does not match", name: "different height does not match",
tamper: func(r *signature.Request) { r.Height = 601 }, tamper: func(r *imagerequest.Request) { r.Transform.Size.Height = 601 },
}, },
{ {
name: "different format does not match", name: "different format does not match",
tamper: func(r *signature.Request) { r.Format = testFormatPNG }, tamper: func(r *imagerequest.Request) { r.Transform.Format = testFormatPNG },
}, },
} }
} }
@@ -248,15 +258,18 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
signer := signature.New("test-secret-key") signer := signature.New("test-secret-key")
// Base request that we'll sign, then tamper with individual fields. // Base request that we'll sign, then tamper with individual fields.
baseReq := func() *signature.Request { baseReq := func() *imagerequest.Request {
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "token=abc", Path: testPath,
Width: 800, Query: "token=abc",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Expires: time.Now().Add(1 * time.Hour), Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
Expires: time.Now().Add(1 * time.Hour),
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -309,14 +322,17 @@ func TestSigner_Sign_ExactHostInData(t *testing.T) {
sigs := make(map[string]string) sigs := make(map[string]string)
for _, host := range hosts { for _, host := range hosts {
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: host, Source: imagerequest.Source{
SourcePath: testPath, Host: host,
SourceQuery: "", Path: testPath,
Width: 800, Query: "",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Expires: time.Unix(1704067200, 0), Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
Expires: time.Unix(1704067200, 0),
} }
sig := signer.Sign(req) sig := signer.Sign(req)
@@ -334,13 +350,16 @@ func TestSigner_DifferentKeys(t *testing.T) {
signer1 := signature.New("secret-key-1") signer1 := signature.New("secret-key-1")
signer2 := signature.New("secret-key-2") signer2 := signature.New("secret-key-2")
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
Width: 800, Path: testPath,
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Expires: time.Now().Add(1 * time.Hour), Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
Expires: time.Now().Add(1 * time.Hour),
} }
// Sign with key 1 // Sign with key 1
@@ -364,13 +383,16 @@ func TestGenerateSignedURL(t *testing.T) {
signer := signature.New("test-secret-key") signer := signature.New("test-secret-key")
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "", Path: testPath,
Width: 800, Query: "",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
} }
ttl := 1 * time.Hour ttl := 1 * time.Hour
@@ -405,12 +427,15 @@ func TestGenerateSignedURL_OrigSize(t *testing.T) {
signer := signature.New("test-secret-key") signer := signature.New("test-secret-key")
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
Width: 0, // Original size Path: testPath,
Height: 0, },
Format: testFormatPNG, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 0, Height: 0}, // Original size
Format: testFormatPNG,
},
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
@@ -426,13 +451,16 @@ func TestGenerateSignedURL_WithQueryString(t *testing.T) {
signer := signature.New("test-secret-key-for-testing!") signer := signature.New("test-secret-key-for-testing!")
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
SourceQuery: "token=abc&v=2", Path: testPath,
Width: 800, Query: "token=abc&v=2",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
@@ -460,12 +488,15 @@ func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
signer := signature.New("test-secret-key-for-testing!") signer := signature.New("test-secret-key-for-testing!")
req := &signature.Request{ req := &imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: testPath, Host: testHost,
Width: 800, Path: testPath,
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
@@ -485,16 +516,19 @@ func TestGenerateSignedURL_ReadmeQueryExample(t *testing.T) {
signer := signature.New("example-signing-key-for-documentation") signer := signature.New("example-signing-key-for-documentation")
req := signature.Request{ req := imagerequest.Request{
SourceHost: testHost, Source: imagerequest.Source{
SourcePath: "/img", Host: testHost,
SourceQuery: "url=https://origin.example.com/my%20cat.jpg", Path: "/img",
Width: 800, Query: "url=https://origin.example.com/my%20cat.jpg",
Height: 600, },
Format: testFormatWebP, Transform: imagerequest.Transform{
Quality: 85, Size: imagerequest.Size{Width: 800, Height: 600},
FitMode: testFitCover, Format: testFormatWebP,
Expires: time.Unix(1704067200, 0), Quality: 85,
FitMode: testFitCover,
},
Expires: time.Unix(1704067200, 0),
} }
const wantSig = "37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA=" const wantSig = "37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA="
+3 -3
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker-smoke: build the Docker image, start it, and wait up to # script/docker-smoke: build the Docker image, start it, and wait up to
# 30 seconds for its HEALTHCHECK to report healthy. Needs a Docker # 30 seconds for its HEALTHCHECK to report healthy. It is not part of
# daemon, so it is not part of script/check; the Gitea workflow runs it # script/check, which never starts the image, and the Gitea workflow
# after script/cibuild. # does not run it. Run it by hand.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"