Problem/Motivation

crop_file_url_alter() appends a ?h= hash to derivative URLs so that browsers, CDNs, and reverse proxies reload images when the crop position changes. This cache-busting mechanism silently fails for all derivatives produced by image styles that include a format conversion effect (image_convert, image_convert_avif, or any third-party equivalent).

Root Cause

When an image style includes a conversion effect, ImageStyle::buildUri() appends the converted extension to the derivative filename via addExtension():
Source URI: public://path/to/photo.jpeg
Derivative URI: public://styles/large/public/path/to/photo.jpeg.webp
webp - appended by addExtension()

The crop entity is stored against the source URI (photo.jpeg). The hook constructs $file_uri directly from the derivative path:

$file_uri = $match[2] . '://' . $parsed_uri['path'];
// Result: public://path/to/photo.jpeg.webp <- includes appended .webp

getCropFromImageStyleId() then looks up the crop by this URI , which does not match the stored crop entity URI and silently returns NULL. No ?h= hash is appended.

Impact

On any site using the crop module with a format-conversion image effect:

  • All format-converted derivatives (WebP, AVIF, PNG conversion, etc.) never receive the ?h= cache-busting hash
  • Changing a focal point or crop position does not invalidate CDN or browser caches for these derivatives
  • Editors see stale images after crop changes until the CDN TTL expires (potentially days or weeks depending on cache configuration)
  • The failure is completely silent, no error is logged

This was discovered and confirmed on a production Pantheon/Fastly AGCDN deployment where focal point changes were not visible to anonymous users. The agcdn-io-age response header confirmed Fastly IO was serving cached derivatives with 30-day TTL. The root cause traced to ?h= not being appended due to this bug.

Verification

// Crop entity exists for photo.jpeg - confirmed
$crop = Crop::findCrop('public://path/to/photo.jpeg', 'focal_point');
// Returns crop entity

// getCropFromImageStyleId with derivative URI (what the hook currently uses)
$crop = Crop::getCropFromImageStyleId(
  'public://path/to/photo.jpeg.webp',  // .webp suffix causes mismatch
  'my_webp_style'
);
// Returns NULL — crop not found, no ?h= appended

// getCropFromImageStyleId with corrected source URI
$crop = Crop::getCropFromImageStyleId(
  'public://path/to/photo.jpeg',       // correct source URI
  'my_webp_style'
);
// Returns crop entity

Steps to reproduce

  1. Install crop + focal_point on a Drupal site
  2. Create an image style with focal_point_scale_and_crop + image_convert (converting to WebP)
  3. Upload an image and set a focal point
  4. Call ImageStyle::buildUrl() for that image and style
  5. Parse the resulting URL, no ?h= query parameter present
  6. Change the focal point
  7. The URL is identical, CDNs cannot detect the change

With the fix applied, step 4 produces a URL with ?h=HASH, and step 6 produces a URL with a different hash, correctly busting CDN caches.

Proposed resolution

Run a check and then utilize core ImageStyleDownloadController::getUriWithoutConvertedExtension() to provide the proper URL without the extra extensions.

Changes

The attached patch modifies two files:

crop.module

  • Adds use Drupal\image\Controller\ImageStyleDownloadController
  • Inserts the URI correction block in crop_file_url_alter() between the double-hash guard and the getCropFromImageStyleId() call
  • Utilize Core method ImageStyleDownloadController::class, 'getUriWithoutConvertedExtension' to get the right URL

tests/src/Kernel/CropFileUrlAlterTest.php (new file) new Kernel tests covering five scenarios:

  1. testNonConvertingStyleAppendsHash
    • Scale only, no conversion
    • Baseline behavior unchanged
  2. testWebpConvertingStyleAppendsHash
    • JPEG/PNG > WebP
    • Primary regression test for this bug
  3. testAvifConvertingStyleAppendsHash
    • JPEG/PNG > AVIF
    • Extension-agnostic coverage
  4. testConvertingStyleWithNoCropEntityDoesNotAppendHash
    • WebP, no crop entity
    • No regression when crop absent
  5. testDoubleHashGuardPreservesExistingHash
    • URL already has `?h=`
    • Double-hash guard works

Tests extend the existing CropUnitTestBase kernel test infrastructure and include #[RunTestsInSeparateProcesses] to address the deprecation present across all existing crop kernel tests (deprecated in drupal:11.3.0).

Relationship to CDN cache invalidation

The ?h= hash mechanism is the correct long-term solution for CDN cache invalidation on focal point changes. When the crop position changes, the hash changes, the URL changes, and CDNs treat the new URL as a cache MISS, serving a fresh derivative with the correct crop automatically. Without this fix, sites using WebP or AVIF conversion with a CDN have no reliable cache invalidation mechanism for focal point changes.

Affected Versions

All versions of the crop module on sites using Drupal core's image conversion effects or any third-party effect implementing ImageEffectInterface::getDerivativeExtension() to change the output format.

AI has been used during this contribution

Used for a discovery and full explanation of the issue and consulted for engineering decisions to use ImageStyleDownloadController::getUriWithoutConvertedExtension(). This small change in code has been reviewed, tested and well understood. Additional Test coverave for this scenario are the bigger AI code contribution in this patch.

Issue fork crop-3583142

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

whthat created an issue. See original summary.

whthat’s picture

Status: Active » Needs review
whthat’s picture

ImageStyleDownloadController::getUriWithoutConvertedExtension() solves the same problem with less code, but was introduced in Drupal 10.3.0 and is not available across the full supported version range of ^9.3 || ^10 || ^11. This fix uses ImageEffectInterface::getDerivativeExtension() instead, which is available in all supported versions currently.

drdam’s picture

Status: Needs review » Reviewed & tested by the community

Test against Crop 8.x-2.6, Drupal 11.3.5.

It's work.

djdevin’s picture

This issue and code seem entirely AI generated and unreviewed given the symbols, emdashes, and emojis.

AI contributions must be disclosed per Drupal's AI policy: https://www.drupal.org/docs/develop/issues/issue-procedures-and-etiquett...

drdam’s picture

Status: Reviewed & tested by the community » Needs review
whthat’s picture

Issue summary: View changes

I did use AI for discovery and reverse engineering of addExtension() and just added it to the summary. This small piece of code has been reviewed and is well understood with lots of details in queue summary. We are currently using this patch in production and CDN is functioning fine with new URL's. No intention of hiding or passing on reviews on to you with my AI usage.

Also if you prefer more long term change I am happy to make an additional change to check for and use ImageStyleDownloadController::getUriWithoutConvertedExtension() added on Core 10.3.0 and then fall back to the reverse engineering of addExtension() for older versions of core.

whthat’s picture

Patch updated to simplify contribution by check for and use Core's ImageStyleDownloadController::getUriWithoutConvertedExtension() versus the previous reverse engineered addExtension() patch. Summary changes to match.

getUriWithoutConvertedExtension() is the best long term Core based solution without the need to supporting a custom algorithm. Not sure getUriWithoutConvertedExtension() is available across all Crop API supported Core versions, but it has been used in a previously Closed Crop API issue #3293782.