Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
"gnomovision",
"hookspec",
"icanon",
"itok",
"initialisation",
"initialised",
"itok",
"jangregor",
"jonhattan",
"killall",
Expand All @@ -63,6 +63,7 @@
"nohup",
"normalise",
"normalised",
"normalises",
"noscript",
"oleh",
"onlyname",
Expand All @@ -83,6 +84,7 @@
"prompty",
"prophesize",
"pushd",
"recognised",
"rector",
"renovatebot",
"ruleset",
Expand Down Expand Up @@ -110,7 +112,7 @@
"yoyodyne",
"zizmor",
"zizmorcore",
"тест"
"\u0442\u0435\u0441\u0442"
],
"ignorePaths": [
".git/",
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ release is dated by its Drupal.org release node.
was the wrapper's absolute URL encoded into one path segment, a row that
could never match. A file on a private field that moved after a title
change with active updating on left one behind.
- [#3277844](https://www.drupal.org/i/3277844): Staged each upload in a
directory of its own, so two uploads of a file with the same name no longer
share a staged path and an image style preview URL. The image widget showed
the first file's thumbnail for the second. The directory is removed, once
empty, when cron deletes an upload that was never saved. A site with no staging
location configured, or with a bare scheme root such as `private://`,
stages under that root.

## 8.x-1.0-rc2 - 2026-09-07

Expand Down
6 changes: 6 additions & 0 deletions filefield_paths.module
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ function filefield_paths_file_presave(FileInterface $file): void {// phpcs:ignor
\Drupal::service(File::class)->filePresave($file);
}

// @phpstan-ignore-next-line
#[LegacyHook]
function filefield_paths_file_delete(FileInterface $file): void {// phpcs:ignore Drupal.Commenting.FunctionComment.Missing, Squiz.WhiteSpace.FunctionSpacing.Before
\Drupal::service(File::class)->fileDelete($file);
}

// @phpstan-ignore-next-line
#[LegacyHook]
function filefield_paths_file_url_alter(string &$uri): void {// phpcs:ignore Drupal.Commenting.FunctionComment.Missing, Squiz.WhiteSpace.FunctionSpacing.Before
Expand Down
11 changes: 9 additions & 2 deletions src/Hook/FieldWidgetSingleElementForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace Drupal\filefield_paths\Hook;

use Drupal\Component\Utility\Crypt;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\filefield_paths\StagingLocation;
use Drupal\filefield_paths\Utility\FieldItem;
use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure;

Expand Down Expand Up @@ -39,8 +41,13 @@ public function formAlter(array &$element, FormStateInterface $form_state, array
$settings = $context['items']->getFieldDefinition()
->getThirdPartySettings('filefield_paths');
$temp_location = $settings['temp_location'] ?? NULL;
$element['#upload_location'] = $temp_location ?:
$this->getSettings()->get('temp_location');
$temp_location = $temp_location ?: $this->getSettings()->get('temp_location');
// Stage each upload in a directory of its own. Two files with the same
// name would otherwise take the same staged path in turn, and the image
// style preview URL is built from that path. A browser or a CDN then
// shows the first image in place of the second.
// See https://www.drupal.org/i/3277844.
$element['#upload_location'] = StagingLocation::directory($temp_location) . StagingLocation::PREFIX . Crypt::randomBytesBase64(8);
}
}

Expand Down
59 changes: 59 additions & 0 deletions src/Hook/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@

namespace Drupal\filefield_paths\Hook;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\file\FileInterface;
use Drupal\filefield_paths\StagingLocation;

/**
* File relate hook implementations.
Expand All @@ -17,6 +21,22 @@ final class File {

use StringTranslationTrait;

/**
* Constructor.
*
* @param \Drupal\Core\File\FileSystemInterface $fileSystem
* The file system service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
*/
public function __construct(
private readonly FileSystemInterface $fileSystem,
private readonly ConfigFactoryInterface $configFactory,
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}

/**
* Implements hook_entity_base_field_info().
*/
Expand Down Expand Up @@ -50,4 +70,43 @@ public function filePresave(FileInterface $file): void {// phpcs:ignore Squiz.Wh
}
}

/**
* Implements hook_file_delete().
*/
#[Hook('file_delete')]
public function fileDelete(FileInterface $file): void {
// Each upload is staged in a directory of its own, inside a staging
// location. When the file is deleted before it was saved to an entity,
// remove the directory too. The name alone does not prove this module
// made the directory, so it must also sit in a configured staging
// location. rmdir() fails on a directory that still holds files.
$directory = $this->fileSystem->dirname($file->getFileUri());
foreach ($this->stagingLocations() as $location) {
if (StagingLocation::isStagingDirectory($directory, $location)) {
@$this->fileSystem->rmdir($directory);
return;
}
}
}

/**
* Returns every staging location in use.
*
* @return string[]
* The global location, or the default when none is set, and every field
* level override. Each ends in a slash.
*/
private function stagingLocations(): array {
$locations = [
StagingLocation::directory($this->configFactory->get('filefield_paths.settings')->get('temp_location')),
];
foreach ($this->entityTypeManager->getStorage('field_config')->loadMultiple() as $field) {
$override = $field->getThirdPartySetting('filefield_paths', 'temp_location');
if (is_string($override) && $override !== '') {
$locations[] = StagingLocation::directory($override);
}
}
return array_values(array_unique($locations));
}

}
75 changes: 75 additions & 0 deletions src/StagingLocation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Drupal\filefield_paths;

use Drupal\Core\StreamWrapper\StreamWrapperManager;

/**
* Resolves the directory uploads are staged in.
*
* Every upload is staged in a directory of its own inside the staging
* location, so two files with one name never share a staged path. The widget
* builds that directory, and the file delete hook removes it once it is
* empty. Both must agree on what the staging location is.
*/
final class StagingLocation {

/**
* The staging location when none is configured.
*
* Core stages an upload with no destination at the root of the temporary
* scheme. Do the same, so a site whose settings lack a location still
* works.
*/
public const DEFAULT = 'temporary://';

/**
* The prefix of a staging directory name.
*/
public const PREFIX = 'ffp-';

/**
* Normalises a configured staging location to a directory URI.
*
* @param mixed $location
* The configured value. It may be unset or empty.
*
* @return string
* The location with exactly one trailing slash, for example
* "temporary://filefield_paths/", or "temporary://" for a bare scheme
* root. The default when the value is not a URI with a scheme.
*/
public static function directory(mixed $location): string {
if (!is_string($location) || $location === '') {
return self::DEFAULT;
}
$scheme = StreamWrapperManager::getScheme($location);
if ($scheme === FALSE) {
return self::DEFAULT;
}
$target = trim((string) StreamWrapperManager::getTarget($location), '/');
return $target === '' ? $scheme . '://' : $scheme . '://' . $target . '/';
}

/**
* Checks whether a directory is a staging directory inside a location.
*
* @param string $directory
* The directory URI, without a trailing slash.
* @param string $location
* A staging location as returned by directory().
*
* @return bool
* TRUE if the directory sits directly inside the location and carries a
* staging directory name.
*/
public static function isStagingDirectory(string $directory, string $location): bool {
if (!str_starts_with($directory, $location)) {
return FALSE;
}
return (bool) preg_match('#^' . preg_quote(self::PREFIX, '#') . '[A-Za-z0-9_-]+$#', substr($directory, strlen($location)));
}

}
13 changes: 8 additions & 5 deletions tests/src/Functional/FileFieldPathsGeneralTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ public function testUploadFile(): void {
$edit[sprintf('files[%s_0]', $field_name)] = $file_system->realpath($test_file->getFileUri());
$this->submitForm($edit, 'Upload');

// Ensure that the file was put into the Temporary file location.
// Ensure that the file was put into the Temporary file location. Each
// upload is staged in a directory of its own under that location.
$config = $this->config('filefield_paths.settings');
$session->responseContains(\Drupal::service('file_url_generator')->generateString(sprintf('%s/%s', $config->get('temp_location'), $test_file->getFilename())));
$temp_location_url = \Drupal::service('file_url_generator')->generateString($config->get('temp_location'));
$session->responseMatches(sprintf('#%s/ffp-[A-Za-z0-9_-]+/%s#', preg_quote($temp_location_url, '#'), preg_quote($test_file->getFilename(), '#')));

// Save the node.
$this->submitForm([], 'Save');
Expand Down Expand Up @@ -139,9 +141,10 @@ public function testUploadFileWithCustomTempLocation(): void {
$this->submitForm($edit, 'Upload');

// Ensure that the file was put into the custom Temporary file location
// defined on the field configuration (not the global setting).
$generated_url = \Drupal::service('file_url_generator')->generateString($custom_dir . '/' . $test_file->getFilename());
$session->responseContains($generated_url);
// defined on the field configuration (not the global setting). Each
// upload is staged in a directory of its own under that location.
$custom_dir_url = \Drupal::service('file_url_generator')->generateString($custom_dir);
$session->responseMatches(sprintf('#%s/ffp-[A-Za-z0-9_-]+/%s#', preg_quote($custom_dir_url, '#'), preg_quote($test_file->getFilename(), '#')));

// Save the node.
$this->submitForm([], 'Save');
Expand Down
Loading