Skip to content

Add the imgproxy delivery layer - #68

Open
Joker666 wants to merge 13 commits into
mainfrom
feat/delivery-layer
Open

Add the imgproxy delivery layer#68
Joker666 wants to merge 13 commits into
mainfrom
feat/delivery-layer

Conversation

@Joker666

@Joker666 Joker666 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Stack (merge bottom-up)

  1. Restructure processing and service, and close the geometry compatibility gaps #66 Restructure processing and service, and close the geometry compatibility gaps
  2. Process animated sources frame by frame #67 Process animated sources frame by frame
  3. Add the imgproxy delivery layer #68 Add the imgproxy delivery layer
  4. Build the image on Debian trixie for libvips 8.16 #69 Build the image on Debian trixie for libvips 8.16
  5. Correct quality precedence and cache-hit header consistency #70 Correct quality precedence and cache-hit header consistency
  6. Bring the remaining docs up to date #71 Bring the remaining docs up to date
  7. Let width and height fill in a resize the type created #72 Let width and height fill in a resize the type created
  8. Close the roadmap's remaining known gaps #73 Close the roadmap's remaining known gaps
  9. Implement the Pro options that were only ever cheap #74 Implement the Pro options that were only ever cheap
  10. Run the pipeline in imgproxy's stage order #75 Run the pipeline in imgproxy's stage order

Each PR targets the one before it, so its diff shows only its own change. Together they make up the 0.18.0 release.

Stacked on #67.

imgforge processed images correctly and then said almost nothing about them. A response carried a content type and, on a cache hit, a cache status; everything a CDN or a browser uses to avoid asking again was missing, and one URL could only ever mean one format.

This is the largest PR in the stack. The four groups below were built together because they share the config and service plumbing — the request has to carry the client's headers, and the response has to carry what was decided — but they are independent features and can be reviewed one at a time.

Content negotiation

IMGFORGE_ENABLE_WEBP_DETECTION and IMGFORGE_ENABLE_AVIF_DETECTION serve a modern format to clients whose Accept advertises it, where the URL left the format open; IMGFORGE_ENFORCE_WEBP/_AVIF do so over the top of an explicit one. IMGFORGE_ENABLE_CLIENT_HINTS honours Width and DPR.

The negotiated format is part of the cache key and the response carries Vary: Accept. Without both, a shared cache hands an AVIF to a client that said it could not read one. A format this libvips build cannot encode is skipped rather than attempted, so enabling AVIF on a build with no AV1 encoder degrades to the URL's own format instead of returning 400 to every modern browser.

Caching headers

IMGFORGE_TTL, IMGFORGE_CACHE_CONTROL_PASSTHROUGH, IMGFORGE_USE_ETAG (with If-None-Match answered as a 304), IMGFORGE_LAST_MODIFIED_ENABLED, IMGFORGE_SET_CANONICAL_HEADER. All off by default: turning on long-lived client caching during an upgrade can pin stale images in browsers for a year, which is not something to do silently.

Source controls

IMGFORGE_BASE_URL, IMGFORGE_ALLOWED_SOURCES (where https://*.example.com/ matches exactly one subdomain label), IMGFORGE_USER_AGENT, IMGFORGE_MAX_REDIRECTS.

Deployment settings

IMGFORGE_PATH_PREFIX, IMGFORGE_HEALTH_CHECK_PATH (default /health, alongside the existing /status), IMGFORGE_ALLOW_ORIGIN, IMGFORGE_SIGNATURE_SIZE, IMGFORGE_ENABLE_DEBUG_HEADERS, IMGFORGE_DEVELOPMENT_ERRORS_MODE, and per-option defaults for auto_rotate, strip_metadata, keep_copyright, strip_color_profile, preserve_hdr, enforce_thumbnail, return_attachment and quality.

Notes for review

  • Truncated signatures reject a signature of the wrong length outright. Without that, a one-byte signature would pass whenever the configuration allowed one.
  • tests/delivery_integration_tests.rs is new and covers all four groups end to end.

Validation

cargo fmt -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all -- --test-threads=1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f950a0718b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/app.rs Outdated
Comment thread src/service/mod.rs
Comment thread src/handlers.rs Outdated
Comment thread tests/delivery_integration_tests.rs Outdated
Comment thread src/negotiation.rs Outdated
Comment thread src/handlers.rs
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4196886fb7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/config/source.rs Outdated
Comment thread src/service/mod.rs Outdated
Comment thread src/handlers.rs Outdated
Comment thread src/negotiation.rs Outdated
Comment thread src/service/mod.rs
Comment on lines +150 to +154
apply_client_hints(&mut parsed_options, &request.hints);
(
parsed_options.resize.map(|resize| resize.width).unwrap_or(0),
(parsed_options.dpr_factor() * 1000.0).round() as u32,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rounding DPR values in the processed cache key

Client-hint DPR is applied at full f32 precision when processing but rounded to thousandths in the cache key. For example, at width 3840, DPR values 1.0001 and 1.0004 share the 1000 key while apply_dpr can round them to different output widths, causing one client's cached bytes to be returned to the other. Fresh evidence beyond the earlier missing-key issue is this lossy quantization; key by the exact DPR bits or by the effective scaled dimensions.

Useful? React with 👍 / 👎.

Comment thread src/server.rs
@Joker666
Joker666 force-pushed the feat/delivery-layer branch from 4196886 to f3193fa Compare August 17, 2026 05:05
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3193fadec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/service/mod.rs Outdated
Comment thread src/service/mod.rs
Comment thread src/negotiation.rs
Comment thread src/response.rs Outdated
@Joker666
Joker666 force-pushed the feat/delivery-layer branch from f3193fa to 6d5aaff Compare August 21, 2026 03:53
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d5aaffddb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/config/source.rs Outdated
Comment thread src/service/mod.rs Outdated
Comment thread src/service/mod.rs Outdated
@Joker666
Joker666 force-pushed the feat/delivery-layer branch from 6d5aaff to 015c42a Compare August 21, 2026 06:26
@Joker666
Joker666 force-pushed the feat/delivery-layer branch from 015c42a to b7daac2 Compare August 21, 2026 19:03
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7daac24ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/config/source.rs Outdated
Comment thread src/service/mod.rs
Comment thread src/service/mod.rs
Comment thread src/response.rs Outdated
@Joker666
Joker666 force-pushed the feat/delivery-layer branch from b7daac2 to 1e23fa1 Compare August 21, 2026 21:13
imgforge processed images correctly and then said almost nothing about
them. A response carried a content type and, on a cache hit, a cache
status; everything a CDN or a browser uses to avoid asking again was
missing, and one URL could only ever mean one format.

Content negotiation reads Accept and can serve WebP or AVIF to clients
that advertise them, either where the URL left the format open
(detection) or over the top of an explicit one (enforcement). The chosen
format is part of the cache key and the response carries Vary: Accept,
because otherwise a shared cache hands an AVIF to a client that said it
could not read one.

Caching headers follow: a configured TTL or the origin's own
Cache-Control, an ETag over the response bytes with If-None-Match
answered as a 304, Last-Modified, and a canonical Link back to the
source. Client hints let the browser size the image for a URL that did
not.

Alongside those, the settings a deployment needs around them: a base URL
and an allow list for sources, a path prefix and a health endpoint, a
CORS origin, truncated signatures, debug headers, and development error
detail.
@Joker666
Joker666 force-pushed the feat/delivery-layer branch from 34d36cb to 3eb4444 Compare August 21, 2026 23:59
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3eb4444e46

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/service/mod.rs
Comment thread src/config/source.rs Outdated
Comment thread src/service/mod.rs Outdated
Comment thread src/negotiation.rs Outdated
Joker666 and others added 3 commits August 21, 2026 20:18
An allow-list entry is a prefix of the whole URL, but parsing kept only
its path, so https://api.example/render?tenant=public also admitted
?tenant=private and no query at all — wider than the boundary the
operator wrote down. The query is now kept and prefix-matched, and an
entry that names one pins its path exactly: as a prefix of the raw URL,
more path would have to come before the ?, and nothing can.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cached composite validated only the main image on a hit, so a
watermark_url host removed from IMGFORGE_ALLOWED_SOURCES kept serving
its pixels out of the cache. The watermark URL is now checked before the
lookup alongside the main URL, and the entry remembers where the
watermark was actually fetched from — redirects included — so the hit
filter rechecks both sources.

Metadata entries had the same gap one layer down: CachedMetadata carried
no final fetch URL, so an /info answer that had followed a redirect
outlived the policy that admitted its destination. The entry now records
it and a hit revalidates it, the same rule the image cache applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default ratio was stored as Some(1.0), identical to a URL that
explicitly asked for dpr:1, so the hint gate told them apart by value
and overwrote both. The default is now None — presence is the signal —
and a URL that names any ratio, 1 included, keeps it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9e0ad571d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/service/mod.rs Outdated
A raw or skip_processing miss always returned debug: None, so enabling
IMGFORGE_ENABLE_DEBUG_HEADERS silently did nothing for exactly the
requests whose passthrough nature the headers would have made visible.
A passthrough returns the source as the result, so both halves of the
diagnostics describe the same bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bcd36aeb15

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/response.rs
Comment thread src/handlers.rs
Joker666 and others added 2 commits August 21, 2026 20:36
With IMGFORGE_SECRET set, the TTL policy said public — an explicit
invitation for a CDN to store the authorised answer and replay it to a
request carrying no token. Bearer deployments now always say private:
the TTL keeps its max-age, passthrough no longer forwards the origin's
policy (the origin cannot know a token guards it now), and even with
nothing configured the refusal is said out loud so heuristic caching
cannot decide otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Authorization is not a safelisted request header, so a browser holding
a bearer token asks with OPTIONS before it will send the real request.
The router knew only GET, so the preflight got a 405 — and no header on
the eventual GET could repair that. With IMGFORGE_ALLOW_ORIGIN set, an
OPTIONS handler now grants the origin, GET, and the Authorization
header; without it, OPTIONS keeps meaning 405.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27cc355bc0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/handlers.rs
Comment thread src/negotiation.rs Outdated
The successful /info JSON was the one response without
Access-Control-Allow-Origin, so a cross-origin request could pass the
preflight, complete at the server, and still be unreadable to the
caller. And Accept parameter names are case-insensitive, so Q=0 is the
same refusal as q=0 — reading only the lowercase spelling turned an
explicit refusal into the default full weight and served the refused
format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 442336521b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/config/source.rs Outdated
An entry that failed URL parsing fell back to raw-prefix text matching
— the exact bug class the parsed matcher exists to end. A mistyped
https://trusted.example:bad text-matched
https://trusted.example:bad@evil.test/x, whose real host hides behind
userinfo. Parsing is now fallible: a boundary that cannot be enforced
as written is a configuration error naming the entry, not a different
boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 377c79369f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/handlers.rs Outdated
Comment thread src/response.rs Outdated
Every ETag-enabled cache hit re-hashed the whole body on the async
worker — a per-hit SHA-256 for exactly the requests the cache made
cheap. The tag is now computed on the miss, which just produced the
bytes anyway, stored with the entry regardless of the current setting
(the entry outlives the configuration), and served back on hits.

The CORS preflight also granted only Authorization, but If-None-Match
and If-Modified-Since are not safelisted either — a preflight that
blocked them blocked exactly the revalidation this change shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf08442a8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/url.rs Outdated
Comment thread src/service/mod.rs Outdated
Comment thread src/server.rs
Comment thread src/service/mod.rs Outdated
Four hardenings from review. An out-of-range signature_size set by a
library caller was clamped, so 0 silently became a one-byte HMAC —
brute-forceable in about 256 requests; validation now fails closed on
it. The resolved watermark_url joins the cache identity, because a
relative reference composites a different overlay the moment
IMGFORGE_BASE_URL changes while the path stays identical. CORS
preflights no longer spend a rate-limit token — the browser asking
permission is not a fetch, and at a limit of 1 the preflight starved
the request it was asking for. And the miss-path entity tag is hashed
once, inside the blocking task that produced the bytes, instead of
twice on the async worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e45793b75d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/service/mod.rs Outdated
Comment thread src/negotiation.rs
Joker666 and others added 2 commits August 21, 2026 21:20
A hit rebuilt its headers from empty source metadata, so a passthrough
no-store — the origin explicitly forbidding storage — vanished the
moment the cache started answering, and Last-Modified went with it. The
entry now stores the origin's Cache-Control and Last-Modified and a hit
keeps saying what the origin said.

The Width client hint is attacker-adjacent input: a signature covers
the path, not the headers, so a reusable signed URL that leaves its
width to hints must not let Width: 1000000 size the pipeline. The hint
is now bounded by the configured result ceiling when one is in force
and by a 16384px hard cap when none is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Negotiation offers only what the build can encode, so the two tests
that expect AVIF to win assumed an encoder the CI runner's libvips does
not have — they have been failing there since they were written. The
same guard the save tests use skips them where the offer cannot exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant