diff --git a/README.md b/README.md index 4765703..50f39ef 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,12 @@ than once, is refused with 400. - ``: one of `orig`, `png`, `jpeg`, `webp` - ``: `orig` or `x` (e.g. `800x600`) +An image is served with `Cache-Control: public, max-age=, immutable`. +When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), +`max-age` is the whole seconds left until then, so no browser or proxy cache +keeps the image after pixa would refuse the URL. A URL with no expiry gets one +year. `immutable` only stops a client revalidating while its copy is fresh. + The login form (`POST /`) is limited to 5 attempts per minute per client address, counting an IPv6 client by its /64; an attempt over the limit is refused with 429 and a `Retry-After` header. Behind a reverse proxy the client diff --git a/TODO.md b/TODO.md index 56d0745..c10609b 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,13 @@ exhaustion # Completed Steps +- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image + routes build `Cache-Control` from the request's `Expires`, which an encrypted + URL's expiry now fills too; `max-age` is one year, or the whole seconds left + until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when + that is sooner, never negative; an allowlisted host's URL that has an `exp` + follows it too; `immutable` stays, as freshness now ends at the expiry; + documented in `README.md`. - 2026-09-28 strip metadata from processed images (closes #82): every output is exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC profile; the image is first turned upright with `AutoRotate` (before sizes are diff --git a/internal/encurl/encurl.go b/internal/encurl/encurl.go index 5c8819c..8815bf0 100644 --- a/internal/encurl/encurl.go +++ b/internal/encurl/encurl.go @@ -103,7 +103,8 @@ func (g *Generator) Parse(token string) (*Payload, error) { } // ToImageRequest converts the payload to an ImageRequest. -// Applies default values for omitted optional fields. +// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL +// that never expires, gives the zero Expires. func (p *Payload) ToImageRequest() *imgcache.ImageRequest { format := p.Format if format == "" { @@ -120,6 +121,11 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest { fitMode = DefaultFitMode } + var expires time.Time + if p.ExpiresAt != 0 { + expires = time.Unix(p.ExpiresAt, 0) + } + return &imgcache.ImageRequest{ SourceHost: p.SourceHost, SourcePath: p.SourcePath, @@ -131,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest { Format: format, Quality: quality, FitMode: fitMode, + Expires: expires, } } diff --git a/internal/handlers/image.go b/internal/handlers/image.go index 00b271e..561ca1d 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -220,6 +220,24 @@ func (s *Handlers) respondImageError( s.respondError(w, "internal error", http.StatusInternalServerError) } +// cacheControl returns the Cache-Control header for an image served through a +// URL that expires at expires, or never when expires is the zero time. A cache +// may keep the image for a year, but not past the URL's expiry, after which +// pixa refuses the URL. The seconds left are rounded down and never negative. +// immutable only stops revalidation while the image is fresh, so it also ends +// at the expiry. +func cacheControl(expires time.Time) string { + const oneYear = 365 * 24 * time.Hour + + maxAge := oneYear + + if !expires.IsZero() { + maxAge = min(maxAge, max(time.Until(expires), 0)) + } + + return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second)) +} + // writeImageResponse writes headers and streams the image content, // handling conditional and HEAD requests. func (s *Handlers) writeImageResponse( @@ -235,7 +253,7 @@ func (s *Handlers) writeImageResponse( } // Cache control headers - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Cache-Control", cacheControl(req.Expires)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) if resp.ETag != "" { diff --git a/internal/handlers/imageenc.go b/internal/handlers/imageenc.go index 8089f64..5effd8b 100644 --- a/internal/handlers/imageenc.go +++ b/internal/handlers/imageenc.go @@ -89,8 +89,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc { w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) } - // Cache headers - encrypted URLs can be cached since they're immutable - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + // Cache headers: max-age ends at the URL's expiry + w.Header().Set("Cache-Control", cacheControl(req.Expires)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) // Stream the response diff --git a/internal/imgcache/imgcache.go b/internal/imgcache/imgcache.go index 7fb069b..a6a591e 100644 --- a/internal/imgcache/imgcache.go +++ b/internal/imgcache/imgcache.go @@ -95,7 +95,8 @@ type ImageRequest struct { FitMode FitMode // Signature is the HMAC signature for non-allowlisted hosts Signature string - // Expires is the signature expiration timestamp + // 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