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
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package imagerequest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &Request{
|
||||
Source: Source{
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
Query: "v=2",
|
||||
},
|
||||
}
|
||||
|
||||
got := req.SourceURL()
|
||||
|
||||
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
||||
if got != want {
|
||||
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &Request{
|
||||
Source: Source{
|
||||
Host: "localhost:8080",
|
||||
Path: testPathCat,
|
||||
},
|
||||
AllowHTTP: true,
|
||||
}
|
||||
|
||||
got := req.SourceURL()
|
||||
|
||||
want := "http://localhost:8080/photos/cat.jpg"
|
||||
if got != want {
|
||||
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &Request{
|
||||
Source: Source{
|
||||
Host: testHostCDN,
|
||||
Path: "/img.jpg",
|
||||
},
|
||||
AllowHTTP: false,
|
||||
}
|
||||
|
||||
got := req.SourceURL()
|
||||
if got != "https://cdn.example.com/img.jpg" {
|
||||
t.Errorf("SourceURL() = %q, want https scheme", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package imagerequest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// URL parsing errors.
|
||||
var (
|
||||
ErrInvalidPath = errors.New("invalid image path")
|
||||
ErrMissingHost = errors.New("missing source host")
|
||||
ErrInvalidHost = errors.New("invalid source host")
|
||||
ErrMissingSize = errors.New("missing size specification")
|
||||
ErrInvalidSize = errors.New("invalid size format")
|
||||
ErrInvalidFormat = errors.New("invalid or unsupported format")
|
||||
ErrDimensionTooLarge = errors.New("dimension exceeds maximum")
|
||||
ErrPathTraversal = errors.New("path traversal detected")
|
||||
)
|
||||
|
||||
// MaxDimension is the maximum allowed width or height, the same as the image
|
||||
// processor's maximum for an output image (imageprocessor.MaxOutputDimension).
|
||||
const MaxDimension = 8192
|
||||
|
||||
// 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
|
||||
// the source's proportions and the other requested side, rounded down but at
|
||||
// least 1, or is the source's own side when the other side is 0 too.
|
||||
func ValidateDimension(name string, value int) error {
|
||||
if value < 0 {
|
||||
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
|
||||
}
|
||||
|
||||
if value > MaxDimension {
|
||||
return fmt.Errorf("%w: %s is above %d",
|
||||
ErrDimensionTooLarge, name, MaxDimension)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png",
|
||||
// and a size with no format, such as "800x600" or "orig"
|
||||
var sizeFormatRegex = regexp.MustCompile(
|
||||
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
|
||||
|
||||
// percentEscapeRegex matches one percent-escape, such as "%2e"
|
||||
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
|
||||
|
||||
// queryEscapeRegex matches a percent-escape, or one character that a browser
|
||||
// percent-encodes in the query of an http or https URL: one outside the
|
||||
// printable ASCII characters "!" to "~", such as a space, a control character
|
||||
// or a character outside ASCII, or one of `"#'<>`. A "%" that does not begin a
|
||||
// percent-escape matches too.
|
||||
var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
|
||||
|
||||
// hostRegex matches the forms the host of an image URL may take: a host name
|
||||
// or an IPv4 address (letters, digits, "-", "_" and "."), or an IPv6 address
|
||||
// in brackets (hex digits, ":" and "."), either one optionally followed by ":"
|
||||
// and a port, which may be empty. Its one group holds the text in brackets,
|
||||
// which parseImageComponents also checks is an IPv6 address.
|
||||
var hostRegex = regexp.MustCompile(
|
||||
`^(?:[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
|
||||
// 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
|
||||
// URL writes as %3F.
|
||||
func ParseImageURL(urlPath string) (*Request, error) {
|
||||
// Remove the /v1/image/ prefix
|
||||
const prefix = "/v1/image/"
|
||||
if !strings.HasPrefix(urlPath, prefix) {
|
||||
return nil, ErrInvalidPath
|
||||
}
|
||||
|
||||
remainder := strings.TrimPrefix(urlPath, prefix)
|
||||
if remainder == "" {
|
||||
return nil, ErrMissingHost
|
||||
}
|
||||
|
||||
return parseImageComponents(remainder)
|
||||
}
|
||||
|
||||
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
||||
// <host>/<path>/<size> for JPEG XL.
|
||||
func parseImageComponents(remainder string) (*Request, error) {
|
||||
// Find the last path segment, which holds "size" or "size.format"
|
||||
lastSlash := strings.LastIndex(remainder, "/")
|
||||
if lastSlash == -1 {
|
||||
return nil, ErrMissingSize
|
||||
}
|
||||
|
||||
sizeFormat := remainder[lastSlash+1:]
|
||||
hostAndPath := remainder[:lastSlash]
|
||||
|
||||
if hostAndPath == "" {
|
||||
return nil, ErrMissingHost
|
||||
}
|
||||
|
||||
// Parse size and format from the last segment
|
||||
size, format, err := parseSizeFormat(sizeFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Split host from path
|
||||
// The first segment is the host, everything after is the path
|
||||
firstSlash := strings.Index(hostAndPath, "/")
|
||||
|
||||
var host, path, query string
|
||||
|
||||
if firstSlash == -1 {
|
||||
// No path, just host (unusual but valid)
|
||||
host = hostAndPath
|
||||
path = "/"
|
||||
} else {
|
||||
host = hostAndPath[:firstSlash]
|
||||
path = hostAndPath[firstSlash:]
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return nil, ErrMissingHost
|
||||
}
|
||||
|
||||
// Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
|
||||
// the host would make Go's URL parser, which the allowlist check and the
|
||||
// fetcher use, read a different host from the one allowlisted and cached
|
||||
hostMatch := hostRegex.FindStringSubmatch(host)
|
||||
if hostMatch == nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
||||
}
|
||||
|
||||
// Go's URL parser refuses brackets around anything but an IPv6 address
|
||||
if inBrackets := hostMatch[1]; inBrackets != "" {
|
||||
addr, err := netip.ParseAddr(inBrackets)
|
||||
if err != nil || !addr.Is6() {
|
||||
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
||||
}
|
||||
}
|
||||
|
||||
// Host names do not depend on letter case, so the allowlist, the upstream
|
||||
// connection limits, the cache and the signature see one spelling of each
|
||||
// host
|
||||
host = strings.ToLower(host)
|
||||
|
||||
// Go's URL parser, which the fetcher uses, reads a "#" as the start of a
|
||||
// fragment and drops the rest, so each text after it would be another
|
||||
// spelling, cached under its own keys, of one upstream request
|
||||
if strings.Contains(path, "#") {
|
||||
return nil, fmt.Errorf(
|
||||
`%w: the source path or query holds a "#"`, ErrInvalidPath)
|
||||
}
|
||||
|
||||
// Extract query string if present in path. It is the source URL's query
|
||||
// as that URL writes it. A character a URL's query cannot hold as it is,
|
||||
// such as a space written %20 in the image URL, is percent-encoded here,
|
||||
// so the query is signed and cached as it is sent upstream
|
||||
if qIndex := strings.Index(path, "?"); qIndex != -1 {
|
||||
query = escapeQuery(path[qIndex+1:])
|
||||
path = path[:qIndex]
|
||||
}
|
||||
|
||||
// The source query names no file on the upstream, so only the host and
|
||||
// the source path are checked for path traversal
|
||||
err = checkPathTraversal(host + path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return &Request{
|
||||
Source: Source{Host: host, Path: path, Query: query},
|
||||
Transform: Transform{Size: size, Format: format},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkPathTraversal detects path traversal attempts in a URL path.
|
||||
// It checks for various attack vectors including:
|
||||
// - Direct ../ sequences
|
||||
// - URL-encoded variants (%2e%2e, %252e%252e)
|
||||
// - Backslash variants (..\)
|
||||
// - Null byte injection (%00)
|
||||
func checkPathTraversal(path string) error {
|
||||
// First, URL-decode the path to catch encoded attacks
|
||||
// Decode multiple times to catch double-encoding
|
||||
decoded := path
|
||||
for range 3 {
|
||||
newDecoded := decodePercentEscapes(decoded)
|
||||
if newDecoded == decoded {
|
||||
break
|
||||
}
|
||||
|
||||
decoded = newDecoded
|
||||
}
|
||||
|
||||
// Normalize backslashes to forward slashes
|
||||
normalized := strings.ReplaceAll(decoded, "\\", "/")
|
||||
|
||||
// Check for null bytes
|
||||
if strings.Contains(normalized, "\x00") {
|
||||
return ErrPathTraversal
|
||||
}
|
||||
|
||||
// Check for parent directory traversal
|
||||
// Look for "/.." or "../" patterns
|
||||
if strings.Contains(normalized, "/../") ||
|
||||
strings.Contains(normalized, "/..") ||
|
||||
strings.HasPrefix(normalized, "../") ||
|
||||
strings.HasSuffix(normalized, "/..") ||
|
||||
normalized == ".." {
|
||||
return ErrPathTraversal
|
||||
}
|
||||
|
||||
// Also check for ".." as a path segment in the original path
|
||||
// This catches cases where the path hasn't been normalized
|
||||
for seg := range strings.SplitSeq(path, "/") {
|
||||
// URL decode the segment
|
||||
decodedSeg, _ := url.PathUnescape(seg)
|
||||
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
||||
|
||||
if decodedSeg == ".." {
|
||||
return ErrPathTraversal
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodePercentEscapes decodes every percent-escape in s, such as %2e, and
|
||||
// keeps a "%" that does not start one as it is, where url.PathUnescape would
|
||||
// refuse the whole string. A source path holding %25, a literal "%", has such
|
||||
// a "%" after one more decoding.
|
||||
func decodePercentEscapes(s string) string {
|
||||
return percentEscapeRegex.ReplaceAllStringFunc(s, func(escape string) string {
|
||||
decoded, _ := url.PathUnescape(escape)
|
||||
|
||||
return decoded
|
||||
})
|
||||
}
|
||||
|
||||
// escapeQuery returns a source query as a URL's query holds it: each
|
||||
// character that queryEscapeRegex names, such as a space, percent-encoded,
|
||||
// and the rest, the query's percent-escapes included, kept as they are. The
|
||||
// result is unchanged by a second escapeQuery.
|
||||
func escapeQuery(query string) string {
|
||||
return queryEscapeRegex.ReplaceAllStringFunc(query, func(match string) string {
|
||||
if percentEscapeRegex.MatchString(match) {
|
||||
return match
|
||||
}
|
||||
|
||||
return url.PathEscape(match)
|
||||
})
|
||||
}
|
||||
|
||||
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
|
||||
func parseSizeFormat(s string) (Size, Format, error) {
|
||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||
if matches == nil {
|
||||
return Size{}, "", ErrInvalidSize
|
||||
}
|
||||
|
||||
var (
|
||||
size Size
|
||||
formatStr string
|
||||
)
|
||||
|
||||
if matches[4] == "orig" {
|
||||
// "orig" or "orig.format" pattern
|
||||
size = Size{Width: 0, Height: 0}
|
||||
formatStr = matches[5]
|
||||
} else {
|
||||
// "WxH" or "WxH.format" pattern
|
||||
width, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return Size{}, "", ErrInvalidSize
|
||||
}
|
||||
|
||||
height, err := strconv.Atoi(matches[2])
|
||||
if err != nil {
|
||||
return Size{}, "", ErrInvalidSize
|
||||
}
|
||||
|
||||
size = Size{Width: width, Height: height}
|
||||
formatStr = matches[3]
|
||||
}
|
||||
|
||||
err := ValidateDimension("width", size.Width)
|
||||
if err != nil {
|
||||
return Size{}, "", err
|
||||
}
|
||||
|
||||
err = ValidateDimension("height", size.Height)
|
||||
if err != nil {
|
||||
return Size{}, "", err
|
||||
}
|
||||
|
||||
// A URL that names no format is served, and signed, as JPEG XL
|
||||
if formatStr == "" {
|
||||
return size, FormatJXL, nil
|
||||
}
|
||||
|
||||
format, err := parseFormat(formatStr)
|
||||
if err != nil {
|
||||
return Size{}, "", err
|
||||
}
|
||||
|
||||
return size, format, nil
|
||||
}
|
||||
|
||||
// parseFormat converts a format string to Format.
|
||||
func parseFormat(s string) (Format, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "orig", "original":
|
||||
return FormatOriginal, nil
|
||||
case "jpg", "jpeg":
|
||||
return FormatJPEG, nil
|
||||
case "png":
|
||||
return FormatPNG, nil
|
||||
case "webp":
|
||||
return FormatWebP, nil
|
||||
case "avif":
|
||||
return FormatAVIF, nil
|
||||
case "jxl":
|
||||
return FormatJXL, nil
|
||||
case "gif":
|
||||
return FormatGIF, nil
|
||||
case "auto":
|
||||
return FormatAuto, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package imagerequest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Shared test fixture strings.
|
||||
const (
|
||||
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()
|
||||
|
||||
if got.Source.Host != want.Source.Host {
|
||||
t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
|
||||
}
|
||||
|
||||
if got.Source.Path != want.Source.Path {
|
||||
t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
|
||||
}
|
||||
|
||||
if got.Source.Query != want.Source.Query {
|
||||
t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
|
||||
}
|
||||
|
||||
if got.Transform.Size != want.Transform.Size {
|
||||
t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
|
||||
}
|
||||
|
||||
if got.Transform.Format != want.Transform.Format {
|
||||
t.Errorf("Format = %q, want %q",
|
||||
got.Transform.Format, want.Transform.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *Request
|
||||
}{
|
||||
{
|
||||
name: "basic path with size",
|
||||
input: "/v1/image/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: "original size with 0x0",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||
want: &Request{
|
||||
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "original size with orig keyword",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||
want: &Request{
|
||||
Source: Source{Host: testHostCDN, Path: testPathCat},
|
||||
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with query string",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||
want: &Request{
|
||||
Source: Source{
|
||||
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||
},
|
||||
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deep nested path",
|
||||
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||
want: &Request{
|
||||
Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
|
||||
Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "jpg alias for jpeg",
|
||||
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||
want: &Request{
|
||||
Source: Source{Host: testHostExample, Path: "/img.png"},
|
||||
Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gif format",
|
||||
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||
want: &Request{
|
||||
Source: Source{Host: testHostExample, Path: "/animated.gif"},
|
||||
Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImageURL(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
assertRequest(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseImageURL_AutoFormat verifies that the format auto in an image URL
|
||||
// parses as FormatAuto.
|
||||
func TestParseImageURL_AutoFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageURL() error = %v", err)
|
||||
}
|
||||
|
||||
if got.Transform.Format != FormatAuto {
|
||||
t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageURL_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "missing prefix",
|
||||
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
||||
wantErr: ErrInvalidPath,
|
||||
},
|
||||
{
|
||||
name: "empty path after prefix",
|
||||
input: "/v1/image/",
|
||||
wantErr: ErrMissingHost,
|
||||
},
|
||||
{
|
||||
name: "host only, no path or size",
|
||||
input: "/v1/image/cdn.example.com",
|
||||
wantErr: ErrMissingSize,
|
||||
},
|
||||
{
|
||||
name: "invalid size format",
|
||||
input: "/v1/image/cdn.example.com/photo.jpg/invalid.webp",
|
||||
wantErr: ErrInvalidSize,
|
||||
},
|
||||
{
|
||||
name: "unsupported format",
|
||||
input: "/v1/image/cdn.example.com/photo.jpg/800x600.bmp",
|
||||
wantErr: ErrInvalidFormat,
|
||||
},
|
||||
{
|
||||
name: "dimension too large",
|
||||
input: "/v1/image/cdn.example.com/photo.jpg/10000x600.webp",
|
||||
wantErr: ErrDimensionTooLarge,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseImageURL(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
||||
}
|
||||
|
||||
if !errorIs(err, tt.wantErr) {
|
||||
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImagePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *Request
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "chi wildcard capture",
|
||||
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
want: &Request{
|
||||
Source: Source{
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
},
|
||||
Transform: Transform{
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with leading slash from chi",
|
||||
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
want: &Request{
|
||||
Source: Source{
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
},
|
||||
Transform: Transform{
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImagePath(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assertRequest(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
||||
// that every spelling of its letter case is the same host to the allowlist,
|
||||
// the upstream connection limits and the cache, and that the path keeps its
|
||||
// case.
|
||||
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImagePath() error = %v", err)
|
||||
}
|
||||
|
||||
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
||||
|
||||
if got.Source.Host != testHostCDN {
|
||||
t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
|
||||
}
|
||||
|
||||
if got.Source.Path != "/Photos/Cat.jpg" {
|
||||
t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
||||
// refused: a URL reads what comes before the "@" as a user name and password
|
||||
// and the rest as the host.
|
||||
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{
|
||||
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
} {
|
||||
got, err := ParseImagePath(input)
|
||||
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
||||
|
||||
if !errors.Is(err, ErrInvalidHost) {
|
||||
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
||||
input, err, ErrInvalidHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All path traversal attempts should be rejected
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "simple parent directory",
|
||||
input: "/v1/image/cdn.example.com/../etc/passwd/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "double parent directory",
|
||||
input: "/v1/image/cdn.example.com/../../etc/passwd/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "parent in middle of path",
|
||||
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "encoded parent directory",
|
||||
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "double encoded parent",
|
||||
input: "/v1/image/cdn.example.com/photos/%252e%252e/secret/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "backslash traversal",
|
||||
input: "/v1/image/cdn.example.com/photos/..\\secret/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "mixed slashes",
|
||||
input: "/v1/image/cdn.example.com/photos/../\\../secret/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "null byte injection",
|
||||
input: "/v1/image/cdn.example.com/photos/image.jpg%00.png/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "parent at start of path",
|
||||
input: "/v1/image/cdn.example.com/../800x600.jpeg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseImageURL(tt.input)
|
||||
if err == nil {
|
||||
t.Error("ParseImageURL() should reject path traversal attempts")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPathTraversal) {
|
||||
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// a source query holding %25, the percent-escape of a literal "%", are
|
||||
// accepted as they are, although decoding them once more leaves a "%" that
|
||||
// does not start a percent-escape.
|
||||
func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *Request
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
||||
want: &Request{
|
||||
Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
|
||||
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
||||
want: &Request{
|
||||
Source: Source{
|
||||
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
||||
},
|
||||
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 := ParseImageURL(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
assertRequest(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseImageURL_DoubleEncodedTraversalNextToPercent verifies that a
|
||||
// double-encoded ".." is still refused when the path also holds a "%" that
|
||||
// does not start a percent-escape, either as written or after one more
|
||||
// decoding.
|
||||
func TestParseImageURL_DoubleEncodedTraversalNextToPercent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{
|
||||
"/v1/image/cdn.example.com/photos/%252e%252e/secret/50%25off.jpg/800x600.jpeg",
|
||||
"/v1/image/cdn.example.com/photos/%252e%252e/secret/100%/800x600.jpeg",
|
||||
} {
|
||||
_, err := ParseImageURL(input)
|
||||
t.Logf("ParseImageURL(%q) error = %v", input, err)
|
||||
|
||||
if !errors.Is(err, ErrPathTraversal) {
|
||||
t.Errorf("ParseImageURL(%q) error = %v, want ErrPathTraversal",
|
||||
input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errorIs checks if err matches target (handles wrapped errors).
|
||||
func errorIs(err, target error) bool {
|
||||
if errors.Is(err, target) {
|
||||
return true
|
||||
}
|
||||
// Check if error message contains target message for wrapped errors
|
||||
if err != nil && target != nil {
|
||||
return contains(err.Error(), target.Error())
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr))
|
||||
}
|
||||
|
||||
func containsAt(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package imagerequest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateImageRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req Request
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "within bounds",
|
||||
req: Request{Transform: Transform{
|
||||
Size: Size{Width: 800, Height: 600}, FitMode: FitCover,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "original size and empty fit",
|
||||
req: Request{Transform: Transform{Size: Size{Width: 0, Height: 0}}},
|
||||
},
|
||||
{
|
||||
name: "width over limit",
|
||||
req: Request{Transform: Transform{
|
||||
Size: Size{Width: MaxDimension + 1, Height: 600},
|
||||
}},
|
||||
wantErr: ErrDimensionTooLarge,
|
||||
},
|
||||
{
|
||||
name: "height over limit",
|
||||
req: Request{Transform: Transform{
|
||||
Size: Size{Width: 800, Height: MaxDimension + 1},
|
||||
}},
|
||||
wantErr: ErrDimensionTooLarge,
|
||||
},
|
||||
{
|
||||
name: "negative width",
|
||||
req: Request{Transform: Transform{Size: Size{Width: -1, Height: 600}}},
|
||||
wantErr: ErrInvalidSize,
|
||||
},
|
||||
{
|
||||
name: "invalid fit mode",
|
||||
req: Request{Transform: Transform{
|
||||
Size: Size{Width: 800, Height: 600}, FitMode: "bogus",
|
||||
}},
|
||||
wantErr: ErrInvalidFitMode,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := ValidateImageRequest(&tt.req)
|
||||
if tt.wantErr == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user