check / check (push) Successful in 3m6s
The encrypted /v1/e/ route used the decrypted payload unchecked, so a token could request an over-limit size or an unknown fit mode; the generator turned unparseable numbers into 0. imgcache.ValidateDimension alone holds the MaxDimension bound and is used by the path parser, by the new ValidateImageRequest (which adds ValidateFitMode) and by the generator. Both image routes call ValidateImageRequest, so each answers 400. The generator answers 400 naming the field for a width or height that is not a number or fails that check, a quality that is not a number from 1 to 100, a ttl that is not a number from 0 to the largest the expiry calculation can hold, or an unknown fit. Empty quality is 85; empty ttl never expires. The form's size inputs stop at 8192. Model: opus-4-8 (implementation); opus-5-5 (rework)
202 lines
5.8 KiB
Go
202 lines
5.8 KiB
Go
// Package imgcache provides interfaces and types for the image caching proxy.
|
|
package imgcache
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
|
|
|
// ImageFormat represents supported output image formats.
|
|
type ImageFormat string
|
|
|
|
// Supported image output formats.
|
|
const (
|
|
FormatOriginal ImageFormat = "orig"
|
|
FormatJPEG ImageFormat = "jpeg"
|
|
FormatPNG ImageFormat = "png"
|
|
FormatWebP ImageFormat = "webp"
|
|
FormatAVIF ImageFormat = "avif"
|
|
FormatGIF ImageFormat = "gif"
|
|
)
|
|
|
|
// Size represents requested image dimensions
|
|
type Size struct {
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
// OriginalSize returns true if this represents "keep original size"
|
|
func (s Size) OriginalSize() bool {
|
|
return s.Width == 0 && s.Height == 0
|
|
}
|
|
|
|
// FitMode represents how to fit image into requested dimensions.
|
|
type FitMode string
|
|
|
|
// Supported image fit modes.
|
|
const (
|
|
FitCover FitMode = "cover"
|
|
FitContain FitMode = "contain"
|
|
FitFill FitMode = "fill"
|
|
FitInside FitMode = "inside"
|
|
FitOutside FitMode = "outside"
|
|
)
|
|
|
|
// ValidateFitMode checks if the given fit mode is valid.
|
|
// Returns ErrInvalidFitMode for unrecognized fit modes.
|
|
func ValidateFitMode(fit FitMode) error {
|
|
switch fit {
|
|
case FitCover, FitContain, FitFill, FitInside, FitOutside, "":
|
|
return nil
|
|
default:
|
|
return ErrInvalidFitMode
|
|
}
|
|
}
|
|
|
|
// ValidateImageRequest checks a request's width and height with
|
|
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
|
|
// /v1/image/ route and the encrypted /v1/e/ route validate through this
|
|
// function so a request from either source enforces identical bounds.
|
|
func ValidateImageRequest(req *ImageRequest) error {
|
|
err := ValidateDimension("width", req.Size.Width)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = ValidateDimension("height", req.Size.Height)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ValidateFitMode(req.FitMode)
|
|
}
|
|
|
|
// ImageRequest represents a request for a processed image
|
|
type ImageRequest struct {
|
|
// SourceHost is the origin host (e.g., "cdn.example.com")
|
|
SourceHost string
|
|
// SourcePath is the path on the origin (e.g., "/photos/cat.jpg")
|
|
SourcePath string
|
|
// SourceQuery is the optional query string for the origin URL
|
|
SourceQuery string
|
|
// Size is the requested output dimensions
|
|
Size Size
|
|
// Format is the requested output format
|
|
Format ImageFormat
|
|
// Quality is the output quality (1-100) for lossy formats
|
|
Quality int
|
|
// FitMode is how to fit the image into requested dimensions
|
|
FitMode FitMode
|
|
// Signature is the HMAC signature for non-allowlisted hosts
|
|
Signature string
|
|
// Expires is the signature expiration timestamp
|
|
Expires time.Time
|
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
|
AllowHTTP bool
|
|
}
|
|
|
|
// SourceURL returns the full upstream URL to fetch.
|
|
// Uses http:// scheme when AllowHTTP is true, otherwise https://.
|
|
func (r *ImageRequest) SourceURL() string {
|
|
scheme := "https"
|
|
if r.AllowHTTP {
|
|
scheme = "http"
|
|
}
|
|
|
|
url := scheme + "://" + r.SourceHost + r.SourcePath
|
|
if r.SourceQuery != "" {
|
|
url += "?" + r.SourceQuery
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
// ImageResponse represents a processed image ready to serve
|
|
type ImageResponse struct {
|
|
// Content is the image data reader
|
|
Content io.ReadCloser
|
|
// ContentLength is the size in bytes (-1 if unknown)
|
|
ContentLength int64
|
|
// ContentType is the MIME type of the response
|
|
ContentType string
|
|
// ETag is the entity tag for caching
|
|
ETag string
|
|
// LastModified is when the content was last modified
|
|
LastModified time.Time
|
|
// CacheStatus indicates HIT, MISS, or STALE
|
|
CacheStatus CacheStatus
|
|
// FetchedBytes is the number of bytes fetched from upstream (0 if cache hit)
|
|
FetchedBytes int64
|
|
}
|
|
|
|
// CacheStatus indicates whether the response was served from cache.
|
|
type CacheStatus string
|
|
|
|
// Cache status values for response headers.
|
|
const (
|
|
CacheHit CacheStatus = "HIT"
|
|
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) (*ImageResponse, error)
|
|
|
|
// Warm pre-fetches and caches an image without returning it
|
|
Warm(ctx context.Context, req *ImageRequest) error
|
|
|
|
// Purge removes a cached image
|
|
Purge(ctx context.Context, req *ImageRequest) error
|
|
|
|
// Stats returns cache statistics
|
|
Stats(ctx context.Context) (*CacheStats, error)
|
|
}
|
|
|
|
// CacheStats contains cache statistics
|
|
type CacheStats struct {
|
|
// TotalItems is the number of cached items
|
|
TotalItems int64
|
|
// TotalSizeBytes is the total size of cached content
|
|
TotalSizeBytes int64
|
|
// HitCount is the number of cache hits
|
|
HitCount int64
|
|
// MissCount is the number of cache misses
|
|
MissCount int64
|
|
// HitRate is HitCount / (HitCount + MissCount)
|
|
HitRate float64
|
|
}
|
|
|
|
// SignatureValidator validates request signatures
|
|
type SignatureValidator interface {
|
|
// Validate checks if the signature is valid for the request
|
|
Validate(req *ImageRequest) error
|
|
// Generate creates a signature for a request
|
|
Generate(req *ImageRequest) string
|
|
}
|
|
|
|
// Allowlist checks if a URL is allowlisted (no signature required)
|
|
type Allowlist interface {
|
|
// IsAllowlisted returns true if the URL doesn't require a signature
|
|
IsAllowlisted(u *url.URL) bool
|
|
}
|
|
|
|
// Storage handles persistent storage of cached content
|
|
type Storage interface {
|
|
// Store saves content and returns its hash
|
|
Store(ctx context.Context, content io.Reader) (hash string, err error)
|
|
// Load retrieves content by hash
|
|
Load(ctx context.Context, hash string) (io.ReadCloser, error)
|
|
// Delete removes content by hash
|
|
Delete(ctx context.Context, hash string) error
|
|
// Exists checks if content exists
|
|
Exists(ctx context.Context, hash string) (bool, error)
|
|
}
|