Skip to content

fix(#3121826): add on-demand image style delivery for temporary:// staged files - #5

Open
Decipher wants to merge 8 commits into
8.x-1.xfrom
feature/3121826-temporary_image_style
Open

fix(#3121826): add on-demand image style delivery for temporary:// staged files#5
Decipher wants to merge 8 commits into
8.x-1.xfrom
feature/3121826-temporary_image_style

Conversation

@Decipher

@Decipher Decipher commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes broken image thumbnails on the node edit form when File (Field) Paths
stages uploaded files in temporary://. Image style derivatives are now
generated and served on demand via a dedicated delivery route, so the default
temporary://filefield_paths temp location works without switching to
private://.

Closes #3121826

Background

When an image field uses File (Field) Paths, uploaded files are staged in a
temporary location (temporary://filefield_paths by default) and moved to
their final path only when the entity is saved. Drupal core's image style
delivery controller does not handle temporary:// URIs, so derivative URLs
return 404 — causing the "broken thumbnail" symptom reported in the issue.

The workaround was to switch temp_location to private://filefield_paths,
but this requires the private filesystem to be configured and adds access
overhead that isn't needed in most cases.

How it works

  1. hook_file_url_alter() — intercepts derivative URIs matching
    temporary://styles/{style}/temporary/{file} and rewrites them to a
    dedicated route.
  2. Delivery route (/system/files/styles/{image_style}/temporary) —
    delegates to core's ImageStyleDownloadController::deliver() with
    scheme: temporary, so derivative generation and itok validation work
    exactly as they do for public/private files.
  3. Access checker (_ffp_temp_image_style) — restricts the route to
    files within the configured FFP temp subdirectory, preventing arbitrary
    temporary:// file access. When temp_location uses a non-temporary
    scheme (e.g. private://), the checker returns neutral so the normal
    private file delivery path applies.

Changes

File Type Purpose
src/Hook/FileUrlHooks.php New hook_file_url_alter() — rewrites temporary derivative URIs
src/Access/ImageStyleTemporaryAccessCheck.php New Access checker scoped to FFP temp subdirectory
filefield_paths.routing.yml Modified Delivery route for temporary image styles
filefield_paths.services.yml Modified Register access checker + FileUrlHooks service
tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php New 4 tests: derivative served, itok validation, subdir restriction, private:// unaffected

Test plan

  • DRUPAL_VERSION=10 make lint passes
  • DRUPAL_VERSION=10 make test passes
  • Manual: image field with FFP temp location set to temporary://filefield_paths — thumbnails appear on node edit form

Summary by CodeRabbit

  • New Features

    • Added on-demand image-style delivery for files in temporary storage.
    • Temporary image URLs are automatically rewritten for derivative generation and delivery.
    • Matching temporary files can be served inline from configured staging directories.
  • Security

    • Blocks unauthorized paths, missing files, invalid locations, and directory traversal attempts.
    • Private temporary storage remains unaffected.
  • Tests

    • Added coverage for URL rewriting, derivative delivery, access control, nested paths, and invalid requests.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: baec3416-5684-4d72-88e3-a7fe45725f1a

📥 Commits

Reviewing files that changed from the base of the PR and between 5ac9473 and d7551d0.

📒 Files selected for processing (1)
  • filefield_paths.module

📝 Walkthrough

Walkthrough

Adds temporary image-style delivery for files under the configured temporary staging directory. The change adds routing, access validation, URL rewriting, inline download handling, and kernel and functional tests.

Changes

Temporary Image Style Delivery

Layer / File(s) Summary
Route and service wiring
filefield_paths.routing.yml, filefield_paths.services.yml
Adds the temporary image-style route and registers the access checker and URL hook services.
Temporary URI rewriting and download headers
src/Hook/FileUrlHooks.php, filefield_paths.module
Rewrites matching temporary image-style URIs and grants inline access for files within the configured temporary subdirectory.
Temporary image-style access validation
src/Access/ImageStyleTemporaryAccessCheck.php
Validates configuration, file paths, traversal segments, and the required temporary staging prefix.
Temporary delivery test coverage
tests/src/Functional/*, tests/src/Kernel/*, .cspell.json
Tests successful delivery, rejected requests, URI rewriting, download headers, alternate locations, and spell-check configuration.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Mergeability Score: 🟠 High · up to d7551

The new temporary image-style delivery path can misroute unrelated temporary derivatives and may allow path traversal to bypass the intended directory boundary, potentially exposing files that should not be downloadable. The PR is not merge-ready until the route scoping and traversal checks are corrected.

Sequence Diagram(s)

sequenceDiagram
  participant Drupal
  participant FileUrlHooks
  participant Router
  participant AccessChecker as ImageStyleTemporaryAccessCheck
  participant Controller as ImageStyleDownloadController

  Drupal->>FileUrlHooks: Alter temporary image-style URI
  FileUrlHooks->>Router: Build temporary delivery URL
  Router->>AccessChecker: Validate file query parameter
  AccessChecker-->>Router: Allow or forbid request
  Router->>Controller: Deliver derivative with temporary scheme
  Controller-->>Drupal: Return image response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: on-demand image style delivery for temporary staged files.
Docstring Coverage ✅ Passed Docstring coverage is 90.48% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/3121826-temporary_image_style

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Decipher
Decipher force-pushed the feature/3121826-temporary_image_style branch from a8f056d to 4da2b52 Compare June 18, 2026 09:26
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php (1)

100-119: ⚡ Quick win

Add a regression test for ../ in file query param.

Current negative-path tests are good, but a traversal-shaped file value (for example filefield_paths/../...) should be explicitly asserted as 403 to lock the boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php` around lines
100 - 119, Add a new test method in the FileFieldPathsImageStyleTemporaryTest
class that specifically tests path traversal attempts using `../` in the file
query parameter. The test should follow the same pattern as
testFileOutsideSubdirReturns403 and testEmptyFileParamReturns403, constructing a
URL where the file parameter contains a traversal sequence like
filefield_paths/../, then call drupalGet with that URL, and assert the response
status code is 403 to ensure path traversal attempts are properly blocked.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Access/ImageStyleTemporaryAccessCheck.php`:
- Around line 35-45: The access check in ImageStyleTemporaryAccessCheck needs to
enforce both the temporary scheme and prevent path traversal. First, validate
that the temp_location configuration value starts with the temporary:// scheme
before using it for the prefix check. Additionally, add a check to reject file
paths containing traversal segments like ../ to prevent out-of-scope access
patterns. Both validations should return AccessResult::forbidden() if they fail,
ensuring the str_starts_with check on line 44 cannot be bypassed through path
traversal or non-temporary scheme values.
- Around line 31-48: The ImageStyleTemporaryAccessCheck method has multiple
early return statements that create AccessResult objects without cache metadata.
The early returns at lines 32 and 41 (when file is empty or subdir is invalid)
return AccessResult::forbidden() without any cacheability information, while the
final return statement includes addCacheContexts and addCacheTags calls. Add the
same cache contexts ['url.query_args:file'] and cache tags
['config:filefield_paths.settings'] to all AccessResult returns in the method.
This ensures that all forbidden access decisions are properly cached and
invalidated when the file parameter or configuration changes, preventing stale
cached results.

---

Nitpick comments:
In `@tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php`:
- Around line 100-119: Add a new test method in the
FileFieldPathsImageStyleTemporaryTest class that specifically tests path
traversal attempts using `../` in the file query parameter. The test should
follow the same pattern as testFileOutsideSubdirReturns403 and
testEmptyFileParamReturns403, constructing a URL where the file parameter
contains a traversal sequence like filefield_paths/../, then call drupalGet with
that URL, and assert the response status code is 403 to ensure path traversal
attempts are properly blocked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2328c92f-e5a5-43ba-b3e3-c13f1abaf323

📥 Commits

Reviewing files that changed from the base of the PR and between 5be2c0d and aa3805d.

📒 Files selected for processing (6)
  • .cspell.json
  • filefield_paths.routing.yml
  • filefield_paths.services.yml
  • src/Access/ImageStyleTemporaryAccessCheck.php
  • src/Hook/FileUrlHooks.php
  • tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php

Comment thread src/Access/ImageStyleTemporaryAccessCheck.php Outdated
Comment thread src/Access/ImageStyleTemporaryAccessCheck.php

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
src/Access/ImageStyleTemporaryAccessCheck.php (1)

48-58: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize $subdir to match $normalized_file handling.

The $file path is normalized on line 53 (leading slashes trimmed), but $subdir is used raw from getTarget(). If the configured temp_location has a trailing slash (e.g., temporary://filefield_paths/), getTarget() returns filefield_paths/, causing the line 58 check to compare against filefield_paths//—which always fails, incorrectly denying legitimate requests.

Suggested fix
-    $subdir = StreamWrapperManager::getTarget($temp_location);
+    $subdir = trim((string) StreamWrapperManager::getTarget($temp_location), '/');
     if (!is_string($subdir) || $subdir === '') {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Access/ImageStyleTemporaryAccessCheck.php` around lines 48 - 58, The
$subdir variable obtained from StreamWrapperManager::getTarget() is not
normalized like $normalized_file is on line 53. When the configured
temp_location has a trailing slash, getTarget() returns a path with a trailing
slash (e.g., "filefield_paths/"), which causes the str_starts_with check on line
58 to create a double slash ("filefield_paths//"), always failing the
comparison. Normalize $subdir by removing trailing slashes using rtrim($subdir,
'/') immediately after retrieving it from getTarget(), ensuring consistent path
formatting for the subsequent comparison with $normalized_file.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/Access/ImageStyleTemporaryAccessCheck.php`:
- Around line 48-58: The $subdir variable obtained from
StreamWrapperManager::getTarget() is not normalized like $normalized_file is on
line 53. When the configured temp_location has a trailing slash, getTarget()
returns a path with a trailing slash (e.g., "filefield_paths/"), which causes
the str_starts_with check on line 58 to create a double slash
("filefield_paths//"), always failing the comparison. Normalize $subdir by
removing trailing slashes using rtrim($subdir, '/') immediately after retrieving
it from getTarget(), ensuring consistent path formatting for the subsequent
comparison with $normalized_file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9370a0ed-6eee-4380-aa53-70bf944e1381

📥 Commits

Reviewing files that changed from the base of the PR and between aa3805d and 2475b4b.

📒 Files selected for processing (3)
  • filefield_paths.module
  • src/Access/ImageStyleTemporaryAccessCheck.php
  • tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php

@Decipher
Decipher force-pushed the feature/3121826-temporary_image_style branch 3 times, most recently from ea168e8 to 01b6b90 Compare June 23, 2026 00:56
Missed during the base-branch Rector fix (test: convert PHPUnit docblock
annotations to attributes) since this file only exists on this branch.
Drupal 11.4 removed the token-valid shortcut in
ImageStyleDownloadController::deliver() that previously treated
temporary:// as a public scheme ($is_public = $scheme !== 'private'
when the itok was valid). The controller now always invokes
hook_file_download() for non-public schemes.

Without an explicit grant, derivative requests for files staged in
temporary://filefield_paths receive a 403 from
AccessDeniedHttpException, breaking image thumbnails on the node edit
form.

Add hook_file_download() to FileUrlHooks that grants access to source
images inside the configured FFP temp subdirectory. The hook mirrors
core's ImageHooks::filePublish() pattern for private:// derivatives:
it returns Content-Type/Content-Length from the source image so the
controller treats the file as accessible.

Security: the itok derivative token is validated by the controller
before this hook fires, and the route access checker
(ImageStyleTemporaryAccessCheck) already enforces the subdir boundary.
The hook only confirms the source URI lives within that subdir.
@Decipher
Decipher force-pushed the feature/3121826-temporary_image_style branch from 4ccc6a5 to 5ac9473 Compare August 13, 2026 02:08

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Hook/FileUrlHooks.php`:
- Around line 31-45: Update the URI rewriting logic near the temporary-location
check to read the configured staging subdirectory and only rewrite when the
captured path `$m[2]` starts with `$subdir . '/'`; leave unrelated temporary
URIs unchanged. Add a regression test covering
`temporary://styles/.../temporary/other_module/image.png` and verify it is not
converted to the File (Field) Paths route.
- Around line 89-95: Update the containment validation in the file download hook
around StreamWrapperManager::getTarget() to normalize path separators and reject
any .. path segments in the URI before applying the staging-subdirectory prefix
check. Ensure traversal targets return NULL and cannot receive download headers,
while preserving valid in-subdirectory access; add a kernel test covering
temporary://filefield_paths/../other_module/image.png.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6769eecb-0f03-4330-81f7-1733972a4fc3

📥 Commits

Reviewing files that changed from the base of the PR and between 21bdba6 and 5ac9473.

📒 Files selected for processing (5)
  • filefield_paths.module
  • src/Hook/FileUrlHooks.php
  • tests/src/Functional/FileFieldPathsImageStyleTemporaryTest.php
  • tests/src/Kernel/FileUrlHooksTest.php
  • tests/src/Kernel/ImageStyleTemporaryAccessCheckTest.php

Comment thread src/Hook/FileUrlHooks.php
Comment on lines +31 to +45
$temp_location = $this->configFactory
->get('filefield_paths.settings')
->get('temp_location') ?? '';

if (StreamWrapperManager::getScheme($temp_location) !== 'temporary') {
return;
}

if (preg_match('#^temporary://styles/([^/]+)/temporary/(.+)$#', $uri, $m)) {
$uri = Url::fromRoute(
'filefield_paths.image_style_temporary',
['image_style' => $m[1]],
['query' => ['file' => $m[2]], 'absolute' => TRUE],
)->toString();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restrict URL rewriting to the configured staging subdirectory.

Line 39 rewrites every temporary://styles/.../temporary/... URI. It also rewrites derivatives for files outside the configured File (Field) Paths directory. The access checker then rejects those requests instead of leaving unrelated temporary files on their normal delivery path.

Read the configured target and require $m[2] to start with $subdir . '/' before creating the route. Add a regression test for temporary://styles/.../temporary/other_module/image.png.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Hook/FileUrlHooks.php` around lines 31 - 45, Update the URI rewriting
logic near the temporary-location check to read the configured staging
subdirectory and only rewrite when the captured path `$m[2]` starts with
`$subdir . '/'`; leave unrelated temporary URIs unchanged. Add a regression test
covering `temporary://styles/.../temporary/other_module/image.png` and verify it
is not converted to the File (Field) Paths route.

Comment thread src/Hook/FileUrlHooks.php
Comment on lines +89 to +95
$subdir = StreamWrapperManager::getTarget($temp_location);
$target = StreamWrapperManager::getTarget($uri);
if (!is_string($subdir) || $subdir === ''
|| !is_string($target)
|| !str_starts_with($target, $subdir . '/')
) {
return NULL;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject traversal segments before granting download access.

temporary://filefield_paths/../other_module/image.png has a target that starts with filefield_paths/. This method returns an inline header although the resolved file is outside the staging subdirectory. The route access checker does not protect other callers of hook_file_download().

Normalize separators and reject .. path segments before the prefix check. Add a kernel test for this URI.

Proposed containment check
     $subdir = StreamWrapperManager::getTarget($temp_location);
     $target = StreamWrapperManager::getTarget($uri);
-    if (!is_string($subdir) || $subdir === ''
-      || !is_string($target)
-      || !str_starts_with($target, $subdir . '/')
-    ) {
+    if (!is_string($subdir) || !is_string($target)) {
+      return NULL;
+    }
+
+    $subdir = trim(str_replace('\\', '/', $subdir), '/');
+    $target = ltrim(str_replace('\\', '/', $target), '/');
+    if ($subdir === ''
+      || preg_match('~(^|/)\.\.(/|$)~', $target)
+      || !str_starts_with($target, $subdir . '/')) {
       return NULL;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$subdir = StreamWrapperManager::getTarget($temp_location);
$target = StreamWrapperManager::getTarget($uri);
if (!is_string($subdir) || $subdir === ''
|| !is_string($target)
|| !str_starts_with($target, $subdir . '/')
) {
return NULL;
$subdir = StreamWrapperManager::getTarget($temp_location);
$target = StreamWrapperManager::getTarget($uri);
if (!is_string($subdir) || !is_string($target)) {
return NULL;
}
$subdir = trim(str_replace('\\', '/', $subdir), '/');
$target = ltrim(str_replace('\\', '/', $target), '/');
if ($subdir === ''
|| preg_match('~(^|/)\.\.(/|$)~', $target)
|| !str_starts_with($target, $subdir . '/')) {
return NULL;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Hook/FileUrlHooks.php` around lines 89 - 95, Update the containment
validation in the file download hook around StreamWrapperManager::getTarget() to
normalize path separators and reject any .. path segments in the URI before
applying the staging-subdirectory prefix check. Ensure traversal targets return
NULL and cannot receive download headers, while preserving valid in-subdirectory
access; add a kernel test covering
temporary://filefield_paths/../other_module/image.png.

The #[Hook('file_download')] attribute on FileUrlHooks::fileDownload()
only fires on Drupal 11. Drupal 10 requires a procedural wrapper marked
with #[LegacyHook] that delegates to the service, matching the existing
pattern used for hook_file_url_alter() and every other hook in the
module.

Without this wrapper, hook_file_download() is never registered on D10,
so ImageStyleDownloadController::deliver() finds no module granting
access for temporary:// source files and throws
AccessDeniedHttpException (403).
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