1 Commits
Author SHA1 Message Date
sneak 5a2aa94691 Remove the code nothing in production calls (closes #312)
check / check (push) Canceled after 0s
Service.Warm, the ImageCache interface, both GenerateSignedURL methods,
signature.ParseParams, imagerequest.ParseImagePath, four symbols of
internal/magic, the image processor's lists of supported formats,
IsEmpty and Count of the allowlist, ssrfSafeDialer, CacheStale,
ErrCacheMiss, ErrNegativeCache, ImageResponse.LastModified and the Load
methods of ContentStorage and VariantStorage had no caller outside
tests. They are gone, with the tests that tested only them.

The handlers tests sign their image URLs with generateSignedURL, a test
helper that builds the URL as the removed generator did. The SSRF dialer
tests call dialSSRFSafe with no operator blocklist, which is all
ssrfSafeDialer did. The tests of ParseImagePath and ContentStorage.Load
call ParseImageURL and LoadWithSize instead. MIMEToImageFormat,
ContentStorage.Store and CleanExpired stay, as pixa calls them.

Model: opus-5-5
2026-10-26 09:40:35 +00:00
27 changed files with 187 additions and 1017 deletions
+16
View File
@@ -30,6 +30,22 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-26 the code nothing in production called is gone (closes #312). The
handlers tests sign their image URLs with `generateSignedURL`, a test helper
that writes them as the removed generator did. Removed:
- `Service.Warm` and the `ImageCache` interface
- `Service.GenerateSignedURL` and `Signer.GenerateSignedURL`
- `signature.ParseParams`
- `imagerequest.ParseImagePath`
- `PeekAndValidate`, `IsSupportedMIMEType`, `ImageFormatToMIME` and
`FormatOriginal` of `internal/magic`
- `SupportedInputFormats` and `SupportedOutputFormats` of the image
processor
- `IsEmpty` and `Count` of the allowlist
- `ssrfSafeDialer`
- `CacheStale`, `ErrCacheMiss`, `ErrNegativeCache` and
`ImageResponse.LastModified`
- `ContentStorage.Load` and `VariantStorage.Load`
- 2026-10-26 cache hits, misses, upstream fetches, the bytes those fetches read - 2026-10-26 cache hits, misses, upstream fetches, the bytes those fetches read
and transforms are counted as Prometheus counters in memory (closes #311). and transforms are counted as Prometheus counters in memory (closes #311).
They are `pixa_cache_hits_total`, `pixa_cache_misses_total`, They are `pixa_cache_hits_total`, `pixa_cache_misses_total`,
-10
View File
@@ -72,13 +72,3 @@ func (w *HostAllowList) IsAllowed(u *url.URL) bool {
return false return false
} }
// IsEmpty returns true if the allow list has no entries.
func (w *HostAllowList) IsEmpty() bool {
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
}
// Count returns the total number of allow list entries.
func (w *HostAllowList) Count() int {
return len(w.exactHosts) + len(w.suffixHosts)
}
-84
View File
@@ -139,87 +139,3 @@ func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
}, },
}) })
} }
func TestHostAllowList_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct {
name string
patterns []string
want bool
}{
{
name: "empty",
patterns: []string{},
want: true,
},
{
name: "nil",
patterns: nil,
want: true,
},
{
name: "whitespace only",
patterns: []string{" ", ""},
want: true,
},
{
name: "has entries",
patterns: []string{"example.com"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns)
if got := w.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
func TestHostAllowList_Count(t *testing.T) {
t.Parallel()
tests := []struct {
name string
patterns []string
want int
}{
{
name: "empty",
patterns: []string{},
want: 0,
},
{
name: "exact hosts only",
patterns: []string{"a.com", "b.com", "c.com"},
want: 3,
},
{
name: "suffix hosts only",
patterns: []string{".a.com", ".b.com"},
want: 2,
},
{
name: "mixed",
patterns: []string{"exact.com", ".suffix.com"},
want: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns)
if got := w.Count(); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want)
}
})
}
}
+6 -10
View File
@@ -75,11 +75,13 @@ func TestFormatForAccept(t *testing.T) {
// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL, // autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL,
// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in // both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in
// the format auto, made with h's image service and generator. // the format auto. The first is signed with testSigningKey, the key of the
// image service newSignedHostServer builds, and the second is made with h's
// generator.
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) { func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
t.Helper() t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{ signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: photoPath, Path: photoPath,
@@ -89,9 +91,6 @@ func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
Format: imagerequest.FormatAuto, Format: imagerequest.FormatAuto,
}, },
}, time.Minute) }, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
token, err := h.encGen.Generate(&imagerequest.Request{ token, err := h.encGen.Generate(&imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
@@ -188,9 +187,9 @@ func TestFormatAuto_ChosenFromAccept(t *testing.T) {
func TestFormatAuto_SignatureCoversAuto(t *testing.T) { func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
t.Parallel() t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) _, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{ signedForAVIF := generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: photoPath, Path: photoPath,
@@ -200,9 +199,6 @@ func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
Format: imagerequest.FormatAVIF, Format: imagerequest.FormatAVIF,
}, },
}, time.Minute) }, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1) target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
if target == signedForAVIF { if target == signedForAVIF {
@@ -27,7 +27,6 @@ import (
// testFixtures contains test data. // testFixtures contains test data.
type testFixtures struct { type testFixtures struct {
handler *Handlers handler *Handlers
service *imgcache.Service
goodHost string goodHost string
} }
@@ -71,7 +70,6 @@ func setupTestHandler(t *testing.T) *testFixtures {
return &testFixtures{ return &testFixtures{
handler: h, handler: h,
service: svc,
goodHost: goodHost, goodHost: goodHost,
} }
} }
@@ -107,9 +107,9 @@ func getMaxAge(t *testing.T, srv http.Handler, target string) int {
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) { func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
t.Parallel() t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) _, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{ signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: photoPath, Path: photoPath,
@@ -119,9 +119,6 @@ func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
}, },
}, time.Minute) }, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
maxAge := getMaxAge(t, srv, signedURL) maxAge := getMaxAge(t, srv, signedURL)
if maxAge < 50 || maxAge > 60 { if maxAge < 50 || maxAge > 60 {
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"time" "time"
@@ -36,6 +37,43 @@ func getImage(t *testing.T, fix *testFixtures, target string) int {
return rec.Code return rec.Code
} }
// generateSignedURL returns the /v1/image/ URL of req, signed with key and
// expiring after ttl, and sets req's expiry. An unset quality or fit of req is
// first set to 85 or cover, the values the route uses when a URL has no q or
// fit. The URL carries q and fit next to sig and exp, so the route verifies it
// for the quality and fit it was signed with.
func generateSignedURL(
key string, req *imagerequest.Request, ttl time.Duration,
) string {
if req.Transform.Quality == 0 {
req.Transform.Quality = 85
}
if req.Transform.FitMode == "" {
req.Transform.FitMode = imagerequest.FitCover
}
req.Expires = time.Now().Add(ttl)
sig := signature.New(key).Sign(req)
// A source query follows the source path after a "?". The image route
// reads the path decoded once, so the whole path is written in Go's own
// percent-encoding of a URL path, which writes "?" as %3F and "%" as %25,
// and leaves "/", ";", ",", "=" and "&" as they are.
sourcePathAndQuery := req.Source.Path
if req.Source.Query != "" {
sourcePathAndQuery += "?" + req.Source.Query
}
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%dx%d.%s",
req.Source.Host, sourcePathAndQuery,
req.Transform.Size.Width, req.Transform.Size.Height, req.Transform.Format)
return fmt.Sprintf("%s?sig=%s&exp=%d&q=%d&fit=%s",
(&url.URL{Path: unescapedPath}).EscapedPath(), sig, req.Expires.Unix(),
req.Transform.Quality, req.Transform.FitMode)
}
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85 // TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
// and fit cover, the values the route uses when a URL has no q or fit, and // and fit cover, the values the route uses when a URL has no q or fit, and
// sends that signature with each q and fit below. // sends that signature with each q and fit below.
@@ -83,8 +121,8 @@ func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
} }
} }
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the // TestHandleImage_GeneratedSignedURLVerifies sends URLs built by
// service's signed-URL generator to the route. // generateSignedURL to the route.
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) { func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
t.Parallel() t.Parallel()
@@ -101,9 +139,8 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
fix := setupTestHandler(t) signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
&imagerequest.Request{
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: "/images/photo.jpg", Path: "/images/photo.jpg",
@@ -115,11 +152,8 @@ func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
FitMode: tt.fitMode, FitMode: tt.fitMode,
}, },
}, time.Hour) }, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL) status := getImage(t, setupTestHandler(t), signedURL)
if status != http.StatusBadGateway { if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway) t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
} }
@@ -178,10 +212,10 @@ func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
} }
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built // TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
// by the service's signed-URL generator for source URLs with a query to the // by generateSignedURL for source URLs with a query to the route, through a
// route, through a chi router: chi routes on the path as the client wrote it // chi router: chi routes on the path as the client wrote it whenever that is
// whenever that is not Go's own encoding of it. A 502 means the signature // not Go's own encoding of it. A 502 means the signature passed, so the route
// passed, so the route read the source path and query that were signed. // read the source path and query that were signed.
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) { func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
t.Parallel() t.Parallel()
@@ -201,9 +235,8 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
fix := setupTestHandler(t) signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
&imagerequest.Request{
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: photoPath, Path: photoPath,
@@ -214,11 +247,8 @@ func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
}, },
}, time.Hour) }, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL) status := getImage(t, setupTestHandler(t), signedURL)
if status != http.StatusBadGateway { if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway) t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
} }
@@ -280,7 +310,7 @@ func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a // TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
// source query that each hold %25, the percent-escape of a literal "%", and // source query that each hold %25, the percent-escape of a literal "%", and
// sends each to the route written as the README says, with that "%" written as // sends each to the route written as the README says, with that "%" written as
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed. // %25, and as generateSignedURL writes it. A 502 means the signature passed.
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) { func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
t.Parallel() t.Parallel()
@@ -326,12 +356,11 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
} }
}) })
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) { t.Run(tt.name+" written by generateSignedURL", func(t *testing.T) {
t.Parallel() t.Parallel()
fix := setupTestHandler(t) signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
&imagerequest.Request{
signedURL, err := fix.service.GenerateSignedURL("", &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: tt.sourcePath, Path: tt.sourcePath,
@@ -342,11 +371,8 @@ func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
}, },
}, time.Hour) }, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL) status := getImage(t, setupTestHandler(t), signedURL)
if status != http.StatusBadGateway { if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway) t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
} }
@@ -17,11 +17,13 @@ import (
) )
// signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the // signedPhotoURL returns a signed /v1/image/ URL, valid for a minute, for the
// JPEG at photoPath on signedHost at 50x50, made with h's image service. // JPEG at photoPath on signedHost at 50x50. It is signed with testSigningKey,
func signedPhotoURL(t *testing.T, h *Handlers) string { // the key of the image service newSignedHostServer builds. It takes a Handlers
// it does not use, so that it has the type of encPhotoURL.
func signedPhotoURL(t *testing.T, _ *Handlers) string {
t.Helper() t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imagerequest.Request{ return generateSignedURL(testSigningKey, &imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: signedHost, Host: signedHost,
Path: photoPath, Path: photoPath,
@@ -31,11 +33,6 @@ func signedPhotoURL(t *testing.T, h *Handlers) string {
Format: imagerequest.FormatJPEG, Format: imagerequest.FormatJPEG,
}, },
}, time.Minute) }, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
return signedURL
} }
// encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the // encPhotoURL returns an encrypted /v1/e/ URL, which never expires, for the
+4 -4
View File
@@ -524,7 +524,7 @@ func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
}) })
} }
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS // The dial-time re-resolution in dialSSRFSafe is what closes the DNS
// rebinding window: even if validateURL saw a public answer earlier, the // rebinding window: even if validateURL saw a public answer earlier, the
// dialer independently re-checks the address it is about to connect to. A full // dialer independently re-checks the address it is about to connect to. A full
// rebinding simulation (a resolver returning public, then private) would mean // rebinding simulation (a resolver returning public, then private) would mean
@@ -544,9 +544,9 @@ func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
t.Run(addr, func(t *testing.T) { t.Run(addr, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := ssrfSafeDialer(context.Background(), "tcp", addr) _, err := dialSSRFSafe(context.Background(), "tcp", addr, nil)
if !errors.Is(err, ErrSSRFBlocked) { if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err) t.Errorf("dialSSRFSafe(%q) = %v, want ErrSSRFBlocked", addr, err)
} }
}) })
} }
@@ -560,7 +560,7 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80") _, err := dialSSRFSafe(ctx, "tcp", testPublicHost+":80", nil)
if err == nil { if err == nil {
t.Fatal("expected a dial error for an unreachable public target") t.Fatal("expected a dial error for an unreachable public target")
} }
-7
View File
@@ -639,13 +639,6 @@ func ipv4CompatibleToIPv4(ip net.IP) net.IP {
return ip return ip
} }
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
// before connecting. New wraps dialSSRFSafe with the operator-supplied
// blocklist; this entry point enforces the built-in ranges alone.
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, nil)
}
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in // dialSSRFSafe re-resolves addr and refuses to connect to any built-in
// internal range or operator-supplied blocked prefix, closing the // internal range or operator-supplied blocked prefix, closing the
// DNS-rebinding window at connect time. // DNS-rebinding window at connect time.
-24
View File
@@ -322,30 +322,6 @@ const (
mimeJXL = "image/jxl" mimeJXL = "image/jxl"
) )
// SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{
mimeJPEG,
mimePNG,
mimeGIF,
mimeWebP,
mimeAVIF,
mimeJXL,
}
}
// SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []imagerequest.Format {
return []imagerequest.Format{
imagerequest.FormatJPEG,
imagerequest.FormatPNG,
imagerequest.FormatGIF,
imagerequest.FormatWebP,
imagerequest.FormatAVIF,
imagerequest.FormatJXL,
}
}
// FormatToMIME converts a Format to its MIME type string. // FormatToMIME converts a Format to its MIME type string.
func FormatToMIME(format imagerequest.Format) string { func FormatToMIME(format imagerequest.Format) string {
switch format { switch format {
@@ -312,22 +312,6 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
} }
} }
func TestImageProcessor_SupportedFormats(t *testing.T) {
t.Parallel()
proc := New(Params{})
inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 {
t.Error("SupportedInputFormats() returned empty slice")
}
outputFormats := proc.SupportedOutputFormats()
if len(outputFormats) == 0 {
t.Error("SupportedOutputFormats() returned empty slice")
}
}
func TestImageProcessor_RejectsOversizedInput(t *testing.T) { func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
t.Parallel() t.Parallel()
-17
View File
@@ -66,23 +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]*)?$`)
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
// Examples:
// - cdn.example.com/photos/cat.jpg/800x600.webp
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
// - cdn.example.com/photos/cat.jpg/orig.png
// - cdn.example.com/photos/cat.jpg/800x600
func ParseImagePath(path string) (*Request, error) {
// Strip leading slash if present (chi may include it)
path = strings.TrimPrefix(path, "/")
if path == "" {
return nil, ErrMissingHost
}
return parseImageComponents(path)
}
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds // ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size> // it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
// for JPEG XL. A source query follows the source path after a "?", which the // for JPEG XL. A source query follows the source path after a "?", which the
+15 -105
View File
@@ -192,76 +192,16 @@ func TestParseImageURL_Errors(t *testing.T) {
} }
} }
func TestParseImagePath(t *testing.T) { // TestParseImageURL_LowerCasesHost verifies that the host is lower-cased, so
t.Parallel()
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
tests := []struct {
name string
input string
want *Request
wantErr bool
}{
{
name: "chi wildcard capture",
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
want: &Request{
Source: Source{
Host: testHostCDN,
Path: testPathCat,
},
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
},
},
},
{
name: "with leading slash from chi",
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
want: &Request{
Source: Source{
Host: testHostCDN,
Path: testPathCat,
},
Transform: Transform{
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseImagePath(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
assertRequest(t, got, tt.want)
})
}
}
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
// that every spelling of its letter case is the same host to the allowlist, // that every spelling of its letter case is the same host to the allowlist,
// the upstream connection limits and the cache, and that the path keeps its // the upstream connection limits and the cache, and that the path keeps its
// case. // case.
func TestParseImagePath_LowerCasesHost(t *testing.T) { func TestParseImageURL_LowerCasesHost(t *testing.T) {
t.Parallel() t.Parallel()
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp") got, err := ParseImageURL("/v1/image/CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
if err != nil { if err != nil {
t.Fatalf("ParseImagePath() error = %v", err) t.Fatalf("ParseImageURL() error = %v", err)
} }
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path) t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
@@ -275,21 +215,21 @@ func TestParseImagePath_LowerCasesHost(t *testing.T) {
} }
} }
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is // TestParseImageURL_RefusesUserinfo verifies that a host holding "@" is
// refused: a URL reads what comes before the "@" as a user name and password // refused: a URL reads what comes before the "@" as a user name and password
// and the rest as the host. // and the rest as the host.
func TestParseImagePath_RefusesUserinfo(t *testing.T) { func TestParseImageURL_RefusesUserinfo(t *testing.T) {
t.Parallel() t.Parallel()
for _, input := range []string{ for _, input := range []string{
"user@cdn.example.com/photos/cat.jpg/800x600.webp", "/v1/image/user@cdn.example.com/photos/cat.jpg/800x600.webp",
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp", "/v1/image/user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
} { } {
got, err := ParseImagePath(input) got, err := ParseImageURL(input)
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err) t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
if !errors.Is(err, ErrInvalidHost) { if !errors.Is(err, ErrInvalidHost) {
t.Errorf("ParseImagePath(%q) error = %v, want %v", t.Errorf("ParseImageURL(%q) error = %v, want %v",
input, err, ErrInvalidHost) input, err, ErrInvalidHost)
} }
} }
@@ -315,6 +255,10 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
name: "parent in middle of path", name: "parent in middle of path",
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg", input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
}, },
{
name: "parent directory in path",
input: "/v1/image/cdn.example.com/photos/../secret/800x600.jpeg",
},
{ {
name: "encoded parent directory", name: "encoded parent directory",
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg", input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
@@ -357,40 +301,6 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
} }
} }
func TestParseImagePath_PathTraversal(t *testing.T) {
t.Parallel()
// Test path traversal via ParseImagePath (chi wildcard)
tests := []struct {
name string
input string
}{
{
name: "parent directory in path",
input: "cdn.example.com/photos/../secret/800x600.jpeg",
},
{
name: "encoded traversal",
input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ParseImagePath(tt.input)
if err == nil {
t.Error("ParseImagePath() should reject path traversal attempts")
}
if !errors.Is(err, ErrPathTraversal) {
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
}
})
}
}
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and // TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
// a source query holding %25, the percent-escape of a literal "%", are // a source query holding %25, the percent-escape of a literal "%", are
// accepted as they are, although decoding them once more leaves a "%" that // accepted as they are, although decoding them once more leaves a "%" that
-6
View File
@@ -14,12 +14,6 @@ import (
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
) )
// Cache errors.
var (
ErrCacheMiss = errors.New("cache miss")
ErrNegativeCache = errors.New("negative cache hit")
)
// maxPendingAccessTimes is how many access times of variants served from // maxPendingAccessTimes is how many access times of variants served from
// the cache, and how many of cached sources a miss used, may each wait to be // the cache, and how many of cached sources a miss used, may each wait to be
// written, at about 80 bytes each. An access time handed over while that // written, at about 80 bytes each. An access time handed over while that
+3 -21
View File
@@ -1,13 +1,7 @@
// Package imgcache provides interfaces and types for the image caching proxy. // Package imgcache provides types for the image caching proxy.
package imgcache package imgcache
import ( import "io"
"context"
"io"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// ImageResponse represents a processed image ready to serve // ImageResponse represents a processed image ready to serve
type ImageResponse struct { type ImageResponse struct {
@@ -19,9 +13,7 @@ type ImageResponse struct {
ContentType string ContentType string
// ETag is the entity tag for caching // ETag is the entity tag for caching
ETag string ETag string
// LastModified is when the content was last modified // CacheStatus indicates HIT or MISS
LastModified time.Time
// CacheStatus indicates HIT, MISS, or STALE
CacheStatus CacheStatus CacheStatus CacheStatus
// FetchedBytes is the number of bytes fetched from upstream (0 if cache hit) // FetchedBytes is the number of bytes fetched from upstream (0 if cache hit)
FetchedBytes int64 FetchedBytes int64
@@ -34,14 +26,4 @@ type CacheStatus string
const ( const (
CacheHit CacheStatus = "HIT" CacheHit CacheStatus = "HIT"
CacheMiss CacheStatus = "MISS" CacheMiss CacheStatus = "MISS"
CacheStale CacheStatus = "STALE"
) )
// ImageCache is the main interface for the image caching proxy
type ImageCache interface {
// Get retrieves a processed image, fetching and processing if necessary
Get(ctx context.Context, req *imagerequest.Request) (*ImageResponse, error)
// Warm pre-fetches and caches an image without returning it
Warm(ctx context.Context, req *imagerequest.Request) error
}
@@ -290,9 +290,9 @@ func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
t.Fatalf("Store() error = %v", err) t.Fatalf("Store() error = %v", err)
} }
stored, err := storage.Load(key) stored, _, err := storage.LoadWithSize(key)
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("LoadWithSize() error = %v", err)
} }
defer func() { _ = stored.Close() }() defer func() { _ = stored.Close() }()
+1 -33
View File
@@ -24,8 +24,7 @@ import (
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
// Service implements the ImageCache interface, orchestrating cache, // Service orchestrates the cache, fetcher, and processor.
// fetcher, and processor.
type Service struct { type Service struct {
cache *Cache cache *Cache
fetcher httpfetcher.Fetcher fetcher httpfetcher.Fetcher
@@ -189,13 +188,6 @@ func (s *Service) Get(
return response, nil return response, nil
} }
// Warm pre-fetches and caches an image without returning it.
func (s *Service) Warm(ctx context.Context, req *imagerequest.Request) error {
_, err := s.Get(ctx, req)
return err
}
// WaitForProcessing waits until no image is being processed, or until ctx // WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then. // ends, and returns how many images were still being processed then.
func (s *Service) WaitForProcessing(ctx context.Context) int { func (s *Service) WaitForProcessing(ctx context.Context) int {
@@ -229,30 +221,6 @@ func (s *Service) ValidateRequest(req *imagerequest.Request) error {
return s.signer.Verify(req) return s.signer.Verify(req)
} }
// GenerateSignedURL generates a signed URL for the given request. The URL
// carries q and fit next to sig and exp, so the image route verifies it for
// the quality and fit it was signed with. An unset quality or fit is first
// set to 85 or cover, the values the route uses when a URL has no q or fit.
func (s *Service) GenerateSignedURL(
baseURL string,
req *imagerequest.Request,
ttl time.Duration,
) (string, error) {
if req.Transform.Quality == 0 {
req.Transform.Quality = 85
}
if req.Transform.FitMode == "" {
req.Transform.FitMode = imagerequest.FitCover
}
// Sets the request's Expires and Signature
path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s",
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.
var errPanicked = errors.New("panic while processing image") var errPanicked = errors.New("panic while processing image")
-32
View File
@@ -62,22 +62,6 @@ func (s *ContentStorage) Store(
return int64(len(data)), nil return int64(len(data)), nil
} }
// Load returns a reader for the content with the given key.
func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) {
path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from source key
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("failed to open content: %w", err)
}
return f, nil
}
// LoadWithSize returns a reader and file size for the content with the given key. // LoadWithSize returns a reader and file size for the content with the given key.
func (s *ContentStorage) LoadWithSize( func (s *ContentStorage) LoadWithSize(
key imagerequest.SourceKey, key imagerequest.SourceKey,
@@ -251,22 +235,6 @@ func (s *VariantStorage) Store(
return int64(len(data)), nil return int64(len(data)), nil
} }
// Load returns a reader for the content at the given key.
func (s *VariantStorage) Load(key imagerequest.VariantKey) (io.ReadCloser, error) {
path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from cache key
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("failed to open content: %w", err)
}
return f, nil
}
// LoadWithContentType opens the content at the given key and returns a // LoadWithContentType opens the content at the given key and returns a
// reader, the file size and the content type, which is read from the // reader, the file size and the content type, which is read from the
// file's first bytes. A missing file is ErrNotFound. A file whose first // file's first bytes. A missing file is ErrNotFound. A file whose first
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
) )
func TestContentStorage_StoreAndLoad(t *testing.T) { func TestContentStorage_StoreAndLoadWithSize(t *testing.T) {
t.Parallel() t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -44,9 +44,9 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
} }
// Load and verify content // Load and verify content
r, err := storage.Load(key) r, _, err := storage.LoadWithSize(key)
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("LoadWithSize() error = %v", err)
} }
defer func() { _ = r.Close() }() defer func() { _ = r.Close() }()
@@ -57,11 +57,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
} }
if !bytes.Equal(loaded, content) { if !bytes.Equal(loaded, content) {
t.Errorf("Load() content = %q, want %q", loaded, content) t.Errorf("LoadWithSize() content = %q, want %q", loaded, content)
} }
} }
func TestContentStorage_LoadNotFound(t *testing.T) { func TestContentStorage_LoadWithSizeNotFound(t *testing.T) {
t.Parallel() t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -71,9 +71,9 @@ func TestContentStorage_LoadNotFound(t *testing.T) {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
} }
_, err = storage.Load(imagerequest.SourceKey("nonexistent")) _, _, err = storage.LoadWithSize(imagerequest.SourceKey("nonexistent"))
if !errors.Is(err, ErrNotFound) { if !errors.Is(err, ErrNotFound) {
t.Errorf("Load() error = %v, want ErrNotFound", err) t.Errorf("LoadWithSize() error = %v, want ErrNotFound", err)
} }
} }
+1 -10
View File
@@ -37,22 +37,13 @@ func TestDetectFormatJPEGXL(t *testing.T) {
} }
} }
// TestJPEGXLMIMEType verifies that image/jxl is a supported input type and // TestJPEGXLMIMEType verifies that image/jxl maps to the format jxl.
// maps to and from the format jxl.
func TestJPEGXLMIMEType(t *testing.T) { func TestJPEGXLMIMEType(t *testing.T) {
t.Parallel() t.Parallel()
if !IsSupportedMIMEType(testMIMEJXL) {
t.Errorf("IsSupportedMIMEType(%q) = false", testMIMEJXL)
}
format, ok := MIMEToImageFormat(testMIMEJXL) format, ok := MIMEToImageFormat(testMIMEJXL)
if format != FormatJXL || !ok { if format != FormatJXL || !ok {
t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true", t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true",
testMIMEJXL, format, ok, FormatJXL) testMIMEJXL, format, ok, FormatJXL)
} }
if got := ImageFormatToMIME(FormatJXL); got != testMIMEJXL {
t.Errorf("ImageFormatToMIME(%q) = %q, want %q", FormatJXL, got, testMIMEJXL)
}
} }
-65
View File
@@ -5,7 +5,6 @@ package magic
import ( import (
"bytes" "bytes"
"errors" "errors"
"io"
"strings" "strings"
) )
@@ -36,7 +35,6 @@ type ImageFormat string
// Supported image output formats. // Supported image output formats.
const ( const (
FormatOriginal ImageFormat = "orig"
FormatJPEG ImageFormat = "jpeg" FormatJPEG ImageFormat = "jpeg"
FormatPNG ImageFormat = "png" FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp" FormatWebP ImageFormat = "webp"
@@ -48,10 +46,6 @@ const (
// MinMagicBytes is the minimum number of bytes needed to detect format. // MinMagicBytes is the minimum number of bytes needed to detect format.
const MinMagicBytes = 12 const MinMagicBytes = 12
// mimeOctetStream is the fallback MIME type for formats without a
// specific MIME type.
const mimeOctetStream = "application/octet-stream"
// Magic byte signatures for supported formats. // Magic byte signatures for supported formats.
// These are effectively constants but Go doesn't support const slices. // These are effectively constants but Go doesn't support const slices.
// //
@@ -190,42 +184,6 @@ func normalizeMIMEType(mimeType string) string {
return strings.TrimSpace(strings.ToLower(mimeType)) return strings.TrimSpace(strings.ToLower(mimeType))
} }
// IsSupportedMIMEType checks if a MIME type is supported for input.
func IsSupportedMIMEType(mimeType string) bool {
normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) {
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF,
MIMETypeJXL, MIMETypeSVG:
return true
default:
return false
}
}
// PeekAndValidate reads the minimum bytes needed for format detection,
// validates against the declared type, and returns a reader that includes
// those bytes for subsequent reading.
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
// Read minimum bytes for detection
buf := make([]byte, MinMagicBytes)
n, err := io.ReadFull(r, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return nil, err
}
buf = buf[:n]
// Validate magic bytes
err = ValidateMagicBytes(buf, declaredType)
if err != nil {
return nil, err
}
// Return a reader that includes the peeked bytes
return io.MultiReader(bytes.NewReader(buf), r), nil
}
// MIMEToImageFormat converts a MIME type to an ImageFormat. // MIMEToImageFormat converts a MIME type to an ImageFormat.
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) { func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
normalized := normalizeMIMEType(mimeType) normalized := normalizeMIMEType(mimeType)
@@ -249,26 +207,3 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
return "", false return "", false
} }
} }
// ImageFormatToMIME converts an ImageFormat to a MIME type string.
func ImageFormatToMIME(format ImageFormat) string {
switch format {
case FormatJPEG:
return string(MIMETypeJPEG)
case FormatPNG:
return string(MIMETypePNG)
case FormatWebP:
return string(MIMETypeWebP)
case FormatGIF:
return string(MIMETypeGIF)
case FormatAVIF:
return string(MIMETypeAVIF)
case FormatJXL:
return string(MIMETypeJXL)
case FormatOriginal:
// Original format passes content through unchanged.
return mimeOctetStream
default:
return mimeOctetStream
}
}
-176
View File
@@ -3,9 +3,6 @@ package magic
import ( import (
"bytes" "bytes"
"errors" "errors"
"io"
"slices"
"strings"
"testing" "testing"
) )
@@ -155,119 +152,6 @@ func TestValidateMagicBytes(t *testing.T) {
} }
} }
func TestIsSupportedMIMEType(t *testing.T) {
t.Parallel()
tests := []struct {
mimeType string
want bool
}{
{testMIMEJPEG, true},
{testMIMEPNG, true},
{testMIMEWebP, true},
{testMIMEGIF, true},
{testMIMEAVIF, true},
{"image/svg+xml", true},
{"IMAGE/JPEG", true},
{testMIMEJPEGParams, true},
{"image/tiff", false},
{"image/bmp", false},
{mimeOctetStream, false},
{"text/plain", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.mimeType, func(t *testing.T) {
t.Parallel()
if got := IsSupportedMIMEType(tt.mimeType); got != tt.want {
t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want)
}
})
}
}
func TestPeekAndValidate(t *testing.T) {
t.Parallel()
jpegMagic := []byte{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
}
pngMagic := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
}
jpegData := slices.Concat(jpegMagic, []byte("rest of jpeg data"))
pngData := slices.Concat(pngMagic, []byte("rest of png data"))
tests := []struct {
name string
data []byte
declaredType string
wantErr bool
wantData []byte
}{
{
name: "valid JPEG",
data: jpegData,
declaredType: testMIMEJPEG,
wantErr: false,
wantData: jpegData,
},
{
name: "valid PNG",
data: pngData,
declaredType: testMIMEPNG,
wantErr: false,
wantData: pngData,
},
{
name: "mismatched type",
data: jpegData,
declaredType: testMIMEPNG,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := bytes.NewReader(tt.data)
result, err := PeekAndValidate(r, tt.declaredType)
if tt.wantErr {
if err == nil {
t.Error("PeekAndValidate() expected error, got nil")
}
return
}
if err != nil {
t.Errorf("PeekAndValidate() unexpected error = %v", err)
return
}
// Read all data from result reader
got, err := io.ReadAll(result)
if err != nil {
t.Errorf("Failed to read result: %v", err)
return
}
if !bytes.Equal(got, tt.wantData) {
t.Errorf(
"PeekAndValidate() data mismatch: got %d bytes, want %d bytes",
len(got), len(tt.wantData),
)
}
})
}
}
func TestMIMEToImageFormat(t *testing.T) { func TestMIMEToImageFormat(t *testing.T) {
t.Parallel() t.Parallel()
@@ -303,35 +187,6 @@ func TestMIMEToImageFormat(t *testing.T) {
} }
} }
func TestImageFormatToMIME(t *testing.T) {
t.Parallel()
tests := []struct {
format ImageFormat
wantMIME string
}{
{FormatJPEG, testMIMEJPEG},
{FormatPNG, testMIMEPNG},
{FormatWebP, testMIMEWebP},
{FormatGIF, testMIMEGIF},
{FormatAVIF, testMIMEAVIF},
{FormatOriginal, mimeOctetStream},
{"unknown", mimeOctetStream},
}
for _, tt := range tests {
t.Run(string(tt.format), func(t *testing.T) {
t.Parallel()
got := ImageFormatToMIME(tt.format)
if got != tt.wantMIME {
t.Errorf("ImageFormatToMIME(%q) = %v, want %v", tt.format, got, tt.wantMIME)
}
})
}
}
func TestNormalizeMIMEType(t *testing.T) { func TestNormalizeMIMEType(t *testing.T) {
t.Parallel() t.Parallel()
@@ -493,34 +348,3 @@ func TestDetectFormatFtypNotAVIF(t *testing.T) {
t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat) t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat)
} }
} }
func TestPeekAndValidatePreservesReader(t *testing.T) {
t.Parallel()
// Ensure that after PeekAndValidate, we can read the complete
// original content
originalContent := append(
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D},
[]byte(strings.Repeat("PNG IDAT chunk data here ", 100))...,
)
r := bytes.NewReader(originalContent)
validated, err := PeekAndValidate(r, testMIMEPNG)
if err != nil {
t.Fatalf("PeekAndValidate() error = %v", err)
}
// Read everything from the validated reader
got, err := io.ReadAll(validated)
if err != nil {
t.Fatalf("io.ReadAll() error = %v", err)
}
if !bytes.Equal(got, originalContent) {
t.Errorf(
"Content mismatch: got %d bytes, want %d bytes",
len(got), len(originalContent),
)
}
}
+11 -4
View File
@@ -513,8 +513,8 @@ func TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort(t *testing.T) {
func signedImageURL(t *testing.T, sourceHost string) string { func signedImageURL(t *testing.T, sourceHost string) string {
t.Helper() t.Helper()
path, sig, exp := signature.New(testSigningKey).GenerateSignedURL( expires := time.Now().Add(time.Hour)
&imagerequest.Request{ sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
Source: imagerequest.Source{ Source: imagerequest.Source{
Host: sourceHost, Host: sourceHost,
Path: ipv6SourcePath, Path: ipv6SourcePath,
@@ -524,9 +524,16 @@ func signedImageURL(t *testing.T, sourceHost string) string {
Quality: 85, Quality: 85,
FitMode: imagerequest.FitCover, FitMode: imagerequest.FitCover,
}, },
}, time.Hour) Expires: expires,
})
return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(exp, 10) // Go's percent-encoding of a URL path writes the brackets of the IPv6
// host as %5B and %5D.
path := (&url.URL{
Path: "/v1/image/" + sourceHost + ipv6SourcePath + "/orig.png",
}).EscapedPath()
return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(expires.Unix(), 10)
} }
// encryptedImageURL returns the encrypted URL of ipv6SourcePath on sourceHost // encryptedImageURL returns the encrypted URL of ipv6SourcePath on sourceHost
+5 -27
View File
@@ -22,11 +22,6 @@ type goldenVector struct {
// padded) HMAC-SHA256 signature for the request with Expires // padded) HMAC-SHA256 signature for the request with Expires
// set to goldenExpiresUnix. // set to goldenExpiresUnix.
wantSignature string wantSignature string
// wantSignedPath is the exact path returned by
// GenerateSignedURL for the request. The signature and
// expiration are returned separately by GenerateSignedURL and
// are not embedded in the path.
wantSignedPath string
} }
// goldenVectors returns the known-answer vectors. The expected values // goldenVectors returns the known-answer vectors. The expected values
@@ -51,7 +46,6 @@ func goldenVectors() []goldenVector {
// 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"
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=", wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
wantSignedPath: testSignedPath,
}, },
{ {
name: "resized with query string", name: "resized with query string",
@@ -71,8 +65,6 @@ func goldenVectors() []goldenVector {
// 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"
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=", wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
"%3Ftoken=abc&v=2/800x600.webp",
}, },
{ {
name: "original size without query", name: "original size without query",
@@ -92,7 +84,6 @@ func goldenVectors() []goldenVector {
// 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"
wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=", wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
}, },
{ {
name: "non-default quality and fit", name: "non-default quality and fit",
@@ -112,22 +103,17 @@ func goldenVectors() []goldenVector {
// 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"
wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=", wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=",
// The path is the same as for the default quality and fit:
// q=40&fit=contain go in the query string next to sig and
// exp (imgcache.Service.GenerateSignedURL adds all four).
wantSignedPath: testSignedPath,
}, },
} }
} }
// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and // TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output for
// the exact generated signed URL path for fully-specified requests with a // fully-specified requests with a hardcoded signing key.
// hardcoded signing key.
// //
// If any of these assertions fail, the signed byte format // If any of these assertions fail, the signed byte format
// ("host:path:query:width:height:format:expiration:quality:fit"), the // ("host:path:query:width:height:format:expiration:quality:fit") or the
// base64url encoding, or the signed URL layout has changed. Update these // base64url encoding has changed. Update these constants only when that
// constants only when that change is intended. // change is intended.
func TestSigner_GoldenVectors(t *testing.T) { func TestSigner_GoldenVectors(t *testing.T) {
t.Parallel() t.Parallel()
@@ -145,14 +131,6 @@ func TestSigner_GoldenVectors(t *testing.T) {
t.Errorf("Sign() = %q, want %q (signed byte format changed?)", t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
gotSignature, tt.wantSignature) gotSignature, tt.wantSignature)
} }
urlReq := tt.req
gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour)
if gotPath != tt.wantSignedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q (signed URL layout changed?)",
gotPath, tt.wantSignedPath)
}
}) })
} }
} }
-54
View File
@@ -8,8 +8,6 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
@@ -79,44 +77,6 @@ func (s *Signer) Verify(req *imagerequest.Request) error {
return nil return nil
} }
// GenerateSignedURL creates a complete URL with signature and expiration.
// Returns the path portion that should be appended to the base URL.
func (s *Signer) GenerateSignedURL(
req *imagerequest.Request, ttl time.Duration,
) (string, string, int64) {
// Set expiration
req.Expires = time.Now().Add(ttl)
exp := req.Expires.Unix()
// Generate signature
sig := s.Sign(req)
req.Signature = sig
// Build the size component
var sizeStr string
if req.Transform.Size.Width == 0 && req.Transform.Size.Height == 0 {
sizeStr = "orig"
} else {
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 "?".
// The image route reads the path decoded once, so the whole path is
// written in Go's own percent-encoding of a URL path, which writes "?"
// as %3F and "%" as %25, and leaves "/", ";", ",", "=" and "&" as they
// are.
sourcePathAndQuery := req.Source.Path
if req.Source.Query != "" {
sourcePathAndQuery += "?" + req.Source.Query
}
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%s.%s",
req.Source.Host, sourcePathAndQuery, sizeStr, req.Transform.Format)
return (&url.URL{Path: unescapedPath}).EscapedPath(), sig, exp
}
// buildSignatureData creates the string to be signed. // buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration:quality:fit" // Format: "host:path:query:width:height:format:expiration:quality:fit"
// The path and query go through escapePercentAndColon, so that a ":" in // The path and query go through escapePercentAndColon, so that a ":" in
@@ -146,17 +106,3 @@ func escapePercentAndColon(s string) string {
return strings.ReplaceAll(s, ":", "%3A") return strings.ReplaceAll(s, ":", "%3A")
} }
// ParseParams extracts signature and expiration from query parameters.
func ParseParams(sig, expStr string) (string, time.Time, error) {
if expStr == "" {
return sig, time.Time{}, nil
}
expUnix, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
}
return sig, time.Unix(expUnix, 0), nil
}
+4 -209
View File
@@ -2,7 +2,6 @@ package signature_test
import ( import (
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -18,8 +17,6 @@ const (
testFormatPNG = "png" testFormatPNG = "png"
testFitCover = "cover" testFitCover = "cover"
testFitContain = "contain" testFitContain = "contain"
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
testSig = "abc123"
) )
func TestSigner_Sign(t *testing.T) { func TestSigner_Sign(t *testing.T) {
@@ -378,140 +375,10 @@ func TestSigner_DifferentKeys(t *testing.T) {
} }
} }
func TestGenerateSignedURL(t *testing.T) { // TestSign_ReadmeQueryExample checks the signature, with the README's key, of
t.Parallel() // the README's example of a source URL with a query, the image at
// https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg.
signer := signature.New("test-secret-key") func TestSign_ReadmeQueryExample(t *testing.T) {
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: testPath,
Query: "",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
ttl := 1 * time.Hour
path, sig, exp := signer.GenerateSignedURL(req, ttl)
// Path should be correct format
if path != testSignedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
}
// Signature should be non-empty
if sig == "" {
t.Error("GenerateSignedURL() produced empty signature")
}
// Expiration should be approximately now + TTL
expTime := time.Unix(exp, 0)
expectedExp := time.Now().Add(ttl)
if expTime.Sub(expectedExp) > time.Second {
t.Errorf("GenerateSignedURL() exp time off by too much")
}
// Request should have been updated with signature and expiration
if req.Signature != sig {
t.Errorf("GenerateSignedURL() didn't update request signature")
}
}
func TestGenerateSignedURL_OrigSize(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: testPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 0, Height: 0}, // Original size
Format: testFormatPNG,
},
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
if path != expectedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
}
}
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key-for-testing!")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: testPath,
Query: "token=abc&v=2",
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
// The path must NOT contain a bare "?" that would be interpreted as
// a query string delimiter. The size segment must appear as the last
// path component.
if strings.Contains(path, "?token=abc") {
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
}
// The size segment must be present in the path
if !strings.Contains(path, "/800x600.webp") {
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
}
// Path should end with the size.format, not with query params
if !strings.HasSuffix(path, "/800x600.webp") {
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
}
}
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key-for-testing!")
req := &imagerequest.Request{
Source: imagerequest.Source{
Host: testHost,
Path: testPath,
},
Transform: imagerequest.Transform{
Size: imagerequest.Size{Width: 800, Height: 600},
Format: testFormatWebP,
},
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
if path != testSignedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
}
}
// TestGenerateSignedURL_ReadmeQueryExample checks the README's example of a
// source URL with a query, the image at
// https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg: its
// signature with the README's key, and the path GenerateSignedURL writes for
// it, with the "?" written as %3F and the "%" as %25.
func TestGenerateSignedURL_ReadmeQueryExample(t *testing.T) {
t.Parallel() t.Parallel()
signer := signature.New("example-signing-key-for-documentation") signer := signature.New("example-signing-key-for-documentation")
@@ -537,76 +404,4 @@ func TestGenerateSignedURL_ReadmeQueryExample(t *testing.T) {
if gotSig != wantSig { if gotSig != wantSig {
t.Errorf("Sign() = %q, want %q", gotSig, wantSig) t.Errorf("Sign() = %q, want %q", gotSig, wantSig)
} }
const wantPath = "/v1/image/cdn.example.com" +
"/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp"
gotPath, _, _ := signer.GenerateSignedURL(&req, time.Hour)
if gotPath != wantPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", gotPath, wantPath)
}
}
func TestParseParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sig string
expStr string
wantSig string
wantErr bool
checkTime bool
}{
{
name: "valid params",
sig: testSig,
expStr: "1704067200",
wantSig: testSig,
wantErr: false,
},
{
name: "empty expiration",
sig: testSig,
expStr: "",
wantSig: testSig,
wantErr: false,
},
{
name: "invalid expiration",
sig: testSig,
expStr: "not-a-number",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
sig, exp, err := signature.ParseParams(tt.sig, tt.expStr)
if tt.wantErr {
if err == nil {
t.Error("ParseParams() expected error, got nil")
}
return
}
if err != nil {
t.Errorf("ParseParams() unexpected error = %v", err)
return
}
if sig != tt.wantSig {
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
}
if tt.expStr != "" && exp.IsZero() {
t.Error("exp should not be zero when expStr is provided")
}
})
}
} }