Implement URL parser for image proxy routes
This commit is contained in:
187
internal/imgcache/urlparser.go
Normal file
187
internal/imgcache/urlparser.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// URL parsing errors.
|
||||
var (
|
||||
ErrInvalidPath = errors.New("invalid image path")
|
||||
ErrMissingHost = errors.New("missing 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")
|
||||
)
|
||||
|
||||
// MaxDimension is the maximum allowed width or height.
|
||||
const MaxDimension = 8192
|
||||
|
||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
||||
|
||||
// ParsedURL contains the parsed components of an image proxy URL.
|
||||
type ParsedURL struct {
|
||||
// Host is the source origin host (e.g., "cdn.example.com")
|
||||
Host string
|
||||
// Path is the path on the origin (e.g., "/photos/cat.jpg")
|
||||
Path string
|
||||
// Query is the optional query string for the origin
|
||||
Query string
|
||||
// Size is the requested output dimensions
|
||||
Size Size
|
||||
// Format is the requested output format
|
||||
Format ImageFormat
|
||||
}
|
||||
|
||||
// ParseImageURL parses a URL path like /v1/image/<host>/<path>/<size>.<format>
|
||||
// Examples:
|
||||
// - /v1/image/cdn.example.com/photos/cat.jpg/800x600.webp
|
||||
// - /v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||
// - /v1/image/cdn.example.com/photos/cat.jpg/orig.png
|
||||
// - /v1/image/cdn.example.com/photos/cat.jpg?q=1/800x600.webp
|
||||
func ParseImageURL(urlPath string) (*ParsedURL, 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
|
||||
}
|
||||
|
||||
// Find the last path segment which contains 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
|
||||
}
|
||||
|
||||
// Extract query string if present in path
|
||||
if qIndex := strings.Index(path, "?"); qIndex != -1 {
|
||||
query = path[qIndex+1:]
|
||||
path = path[:qIndex]
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return &ParsedURL{
|
||||
Host: host,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Size: size,
|
||||
Format: format,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseSizeFormat parses strings like "800x600.webp" or "orig.png"
|
||||
func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||
matches := sizeFormatRegex.FindStringSubmatch(s)
|
||||
if matches == nil {
|
||||
return Size{}, "", ErrInvalidSize
|
||||
}
|
||||
|
||||
var size Size
|
||||
var formatStr string
|
||||
|
||||
if matches[4] == "orig" {
|
||||
// "orig.format" pattern
|
||||
size = Size{Width: 0, Height: 0}
|
||||
formatStr = matches[5]
|
||||
} else {
|
||||
// "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
|
||||
}
|
||||
|
||||
if width > MaxDimension || height > MaxDimension {
|
||||
return Size{}, "", ErrDimensionTooLarge
|
||||
}
|
||||
|
||||
size = Size{Width: width, Height: height}
|
||||
formatStr = matches[3]
|
||||
}
|
||||
|
||||
format, err := parseFormat(formatStr)
|
||||
if err != nil {
|
||||
return Size{}, "", err
|
||||
}
|
||||
|
||||
return size, format, nil
|
||||
}
|
||||
|
||||
// parseFormat converts a format string to ImageFormat.
|
||||
func parseFormat(s string) (ImageFormat, 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 "gif":
|
||||
return FormatGIF, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
|
||||
}
|
||||
}
|
||||
|
||||
// ToImageRequest converts a ParsedURL to an ImageRequest.
|
||||
func (p *ParsedURL) ToImageRequest() *ImageRequest {
|
||||
return &ImageRequest{
|
||||
SourceHost: p.Host,
|
||||
SourcePath: p.Path,
|
||||
SourceQuery: p.Query,
|
||||
Size: p.Size,
|
||||
Format: p.Format,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user