Keep max-age within an expiring image URL's lifetime (closes #63)
check / check (push) Successful in 3m25s
check / check (push) Successful in 3m25s
Both image routes sent Cache-Control: public, max-age=31536000, immutable, so a browser or proxy could keep serving an image for a year after its signed or encrypted URL had expired. The header is now built from the request's Expires: max-age is the whole seconds left until the URL expires, never negative, or one year for a URL with no expiry. ToImageRequest now carries an encrypted URL's expiry onto the request, as the image route already does with exp. immutable stays: it only stops revalidation while a copy is fresh, and freshness now ends at the expiry. README.md documents the header. Model: opus-5-5
This commit is contained in:
@@ -100,6 +100,12 @@ than once, is refused with 400.
|
|||||||
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
|
|
||||||
|
An image is served with `Cache-Control: public, max-age=<seconds>, 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
|
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
|
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
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ exhaustion
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
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
|
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ToImageRequest converts the payload to an ImageRequest.
|
// 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 {
|
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
@@ -120,6 +121,11 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
fitMode = DefaultFitMode
|
fitMode = DefaultFitMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var expires time.Time
|
||||||
|
if p.ExpiresAt != 0 {
|
||||||
|
expires = time.Unix(p.ExpiresAt, 0)
|
||||||
|
}
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imgcache.ImageRequest{
|
||||||
SourceHost: p.SourceHost,
|
SourceHost: p.SourceHost,
|
||||||
SourcePath: p.SourcePath,
|
SourcePath: p.SourcePath,
|
||||||
@@ -131,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
|
Expires: expires,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,24 @@ func (s *Handlers) respondImageError(
|
|||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
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,
|
// writeImageResponse writes headers and streams the image content,
|
||||||
// handling conditional and HEAD requests.
|
// handling conditional and HEAD requests.
|
||||||
func (s *Handlers) writeImageResponse(
|
func (s *Handlers) writeImageResponse(
|
||||||
@@ -235,7 +253,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// 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))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if resp.ETag != "" {
|
if resp.ETag != "" {
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache headers - encrypted URLs can be cached since they're immutable
|
// Cache headers: max-age ends at the URL's expiry
|
||||||
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))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
// Stream the response
|
// Stream the response
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ type ImageRequest struct {
|
|||||||
FitMode FitMode
|
FitMode FitMode
|
||||||
// Signature is the HMAC signature for non-allowlisted hosts
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
Signature string
|
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
|
Expires time.Time
|
||||||
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
|
|||||||
Reference in New Issue
Block a user