From 2bab28749e299e7a3d8121f2712db3a220c05259 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Fri, 28 Aug 2026 02:58:03 +0000 Subject: [PATCH 1/5] chore: stop renovate merging its own pull requests --- renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index ccc14f1..e012c42 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "extends": ["config:recommended"], - "automerge": true, + "automerge": false, "rangeStrategy": "bump", "dependencyDashboard": true, "pinDigests": true, From 226cf3bee2b996a6776f503cc0599a58177b7fa7 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Fri, 28 Aug 2026 02:58:03 +0000 Subject: [PATCH 2/5] style: use null coalescing assignment in the drush commands --- src/Drush/Commands/Commands.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Drush/Commands/Commands.php b/src/Drush/Commands/Commands.php index cce311b..d0133f0 100644 --- a/src/Drush/Commands/Commands.php +++ b/src/Drush/Commands/Commands.php @@ -145,11 +145,9 @@ protected function buildInfo(): array { ]; } - if (!isset($info[$entity_type_name][$bundle])) { - $info[$entity_type_name][$bundle] = [ - '#label' => sprintf('%s (%s)', $bundles_info[$bundle]['label'], $bundle), - ]; - } + $info[$entity_type_name][$bundle] ??= [ + '#label' => sprintf('%s (%s)', $bundles_info[$bundle]['label'], $bundle), + ]; $field_instance = $this->entityFieldManager->getFieldDefinitions($entity_type_name, $bundle)[$field->getName()] ?? NULL; if ($field_instance && is_array($info[$entity_type_name][$bundle])) { $info[$entity_type_name][$bundle][$field_instance->getName()] = sprintf('%s (%s)', $field_instance->getLabel(), $field_instance->getName()); From e5d9c8370677d5fd3d3a036bf6d5bafdc5a4361d Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Wed, 2 Sep 2026 18:57:12 +1000 Subject: [PATCH 3/5] chore: hold rector below 2.6.5, which silently drops the drupal rules --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4b142ba..3580524 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ "drupal/pathauto": "*", "drupal/token": "*", "drupal/redirect": "*", - "drush/drush": ">=12.5.0" + "drush/drush": ">=12.5.0", + "rector/rector": "<2.6.5" }, "conflict": { "drush/drush": "<12.5.0" From 9de444ff68f3a40a32282420a9a133addcbe103a Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Wed, 2 Sep 2026 23:23:35 +0000 Subject: [PATCH 4/5] fix(#3277844): stage each upload in a directory of its own --- filefield_paths.module | 6 ++ src/Hook/FieldWidgetSingleElementForm.php | 10 +++- src/Hook/File.php | 56 +++++++++++++++++++ .../Functional/FileFieldPathsGeneralTest.php | 13 +++-- .../Unit/FieldWidgetSingleElementFormTest.php | 24 +++++++- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/filefield_paths.module b/filefield_paths.module index 43558ec..8713656 100644 --- a/filefield_paths.module +++ b/filefield_paths.module @@ -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 diff --git a/src/Hook/FieldWidgetSingleElementForm.php b/src/Hook/FieldWidgetSingleElementForm.php index 2852943..08e2d11 100644 --- a/src/Hook/FieldWidgetSingleElementForm.php +++ b/src/Hook/FieldWidgetSingleElementForm.php @@ -4,6 +4,7 @@ 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; @@ -39,8 +40,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'] = sprintf('%s/ffp-%s', rtrim((string) $temp_location, '/'), Crypt::randomBytesBase64(8)); } } diff --git a/src/Hook/File.php b/src/Hook/File.php index 5ce3a99..3cb7a48 100644 --- a/src/Hook/File.php +++ b/src/Hook/File.php @@ -4,8 +4,11 @@ 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; @@ -17,6 +20,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(). */ @@ -49,4 +68,41 @@ 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()); + if (!preg_match('#^(.+)/ffp-[A-Za-z0-9_-]+$#', $directory, $matches)) { + return; + } + if (in_array($matches[1], $this->stagingLocations(), TRUE)) { + @$this->fileSystem->rmdir($directory); + } + } + + /** + * Returns every configured staging location. + * + * @return string[] + * The global location and every field level override, without a + * trailing slash. + */ + private function stagingLocations(): array { + $locations = [ + (string) $this->configFactory->get('filefield_paths.settings')->get('temp_location'), + ]; + foreach ($this->entityTypeManager->getStorage('field_config')->loadMultiple() as $field) { + $locations[] = (string) $field->getThirdPartySetting('filefield_paths', 'temp_location'); + } + $locations = array_map(static fn (string $location): string => rtrim($location, '/'), array_filter($locations)); + return array_values(array_unique($locations)); + } + } diff --git a/tests/src/Functional/FileFieldPathsGeneralTest.php b/tests/src/Functional/FileFieldPathsGeneralTest.php index fd76619..4e85482 100644 --- a/tests/src/Functional/FileFieldPathsGeneralTest.php +++ b/tests/src/Functional/FileFieldPathsGeneralTest.php @@ -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'); @@ -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'); diff --git a/tests/src/Unit/FieldWidgetSingleElementFormTest.php b/tests/src/Unit/FieldWidgetSingleElementFormTest.php index 5ee04af..f0eea6c 100644 --- a/tests/src/Unit/FieldWidgetSingleElementFormTest.php +++ b/tests/src/Unit/FieldWidgetSingleElementFormTest.php @@ -45,7 +45,7 @@ public function testUsesFieldLevelTempLocation(): void { $element = ['#type' => 'managed_file']; $hook->formAlter($element, $this->createMock(FormStateInterface::class), ['items' => $items]); - $this->assertSame('private://custom', $element['#upload_location']); + $this->assertMatchesRegularExpression('#^private://custom/ffp-[A-Za-z0-9_-]+$#', $element['#upload_location']); } /** @@ -62,7 +62,27 @@ public function testFallsBackToGlobalTempLocation(): void { $element = ['#type' => 'managed_file']; $hook->formAlter($element, $this->createMock(FormStateInterface::class), ['items' => $items]); - $this->assertSame('temporary://filefield_paths', $element['#upload_location']); + $this->assertMatchesRegularExpression('#^temporary://filefield_paths/ffp-[A-Za-z0-9_-]+$#', $element['#upload_location']); + } + + /** + * Tests that every upload is staged in a directory of its own. + * + * Two uploads of a file with the same name must never share a staged path. + * A shared path gives both files the same image style preview URL. + * + * @see https://www.drupal.org/i/3277844 + */ + public function testEachUploadGetsItsOwnDirectory(): void { + $hook = new FieldWidgetSingleElementForm(static fn () => throw new \LogicException('Config factory should not be called.')); + $items = $this->buildFileFieldItemList(['enabled' => TRUE, 'temp_location' => 'private://custom']); + + $first = ['#type' => 'managed_file']; + $hook->formAlter($first, $this->createMock(FormStateInterface::class), ['items' => $items]); + $second = ['#type' => 'managed_file']; + $hook->formAlter($second, $this->createMock(FormStateInterface::class), ['items' => $items]); + + $this->assertNotSame($first['#upload_location'], $second['#upload_location']); } /** From 6908541a4a13d2270bb1c7492c49201fba0dbdf2 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Wed, 2 Sep 2026 23:23:35 +0000 Subject: [PATCH 5/5] test(#3277844): cover image replacement and staging directory cleanup --- .../FileFieldPathsMultiValueReplaceTest.php | 267 ++++++++++++++++++ .../Kernel/StagingDirectoryCleanupTest.php | 145 ++++++++++ 2 files changed, 412 insertions(+) create mode 100644 tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php create mode 100644 tests/src/Kernel/StagingDirectoryCleanupTest.php diff --git a/tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php b/tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php new file mode 100644 index 0000000..ab5caf3 --- /dev/null +++ b/tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php @@ -0,0 +1,267 @@ + TRUE, + 'transliterate' => TRUE, + ]; + $third_party_settings['filefield_paths'] = [ + 'file_path' => [ + 'value' => '[date:custom:Y]-[date:custom:m]', + 'options' => $options, + ], + 'file_name' => [ + 'value' => '[node:title].[file:ffp-extension-original]', + 'options' => $options, + ], + 'redirect' => TRUE, + 'retroactive_update' => FALSE, + 'active_updating' => FALSE, + ]; + $storage_settings['cardinality'] = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED; + $this->createImageField(self::FIELD, $this->contentType, $storage_settings, [], $third_party_settings); + } + + /** + * Tests that replaced images keep their order, alt text and content. + */ + public function testReplaceAllImages(): void { + // Two sets of three images. Both sets use the same file names, the way a + // camera or an export does. Every file has its own content. + $first = $this->createImages('first', 10); + $second = $this->createImages('second', 40); + + // Create the node with the first set. + $this->drupalGet('node/add/' . $this->contentType); + $alts = []; + foreach ($first as $uri) { + $alts[$this->uploadImage($uri)] = 'first ' . basename($uri); + } + $edit = ['title[0][value]' => 'Photo set']; + foreach ($alts as $delta => $alt) { + $edit[sprintf('%s[%d][alt]', self::FIELD, $delta)] = $alt; + } + $this->submitForm($edit, 'Save'); + $node = $this->drupalGetNodeByTitle('Photo set'); + $this->assertInstanceOf(NodeInterface::class, $node); + $nid = (int) $node->id(); + + $before = $this->assertImages($nid, $first, 'first'); + + // Edit the node. Remove every image, then upload the second set. + $this->drupalGet('node/' . $nid . '/edit'); + $count = count($first); + for ($i = 0; $i < $count; $i++) { + $button = $this->assertSession()->elementExists('css', sprintf('input[name^="%s_"][name$="_remove_button"]', self::FIELD)); + $this->submitForm([], $button->getAttribute('name')); + } + $this->assertSession()->elementNotExists('css', sprintf('input[name^="%s_"][name$="_remove_button"]', self::FIELD)); + $alts = []; + foreach ($second as $uri) { + $alts[$this->uploadImage($uri)] = 'second ' . basename($uri); + } + $edit = []; + foreach ($alts as $delta => $alt) { + $edit[sprintf('%s[%d][alt]', self::FIELD, $delta)] = $alt; + } + $this->submitForm($edit, 'Save'); + + $after = $this->assertImages($nid, $second, 'second'); + + // The new files did not land on top of the old ones. + $this->assertEmpty(array_intersect($before, $after), 'No file URI is shared between the old and the new set.'); + foreach ($before as $index => $uri) { + $this->assertFileExists($this->realpath($uri)); + $this->assertSame(md5_file($this->realpath($first[$index])), md5_file($this->realpath($uri)), 'The old file ' . $uri . ' is unchanged.'); + } + } + + /** + * Tests that a staged upload does not reuse the preview URL of another file. + * + * The image widget shows a thumbnail of the staged file. Its URL is the + * staged path plus an itok that is a hash of that path. The upload of a new + * file with the same name lands on the same staged path once the earlier + * file has moved out, so it gets the same URL. A browser or a CDN that + * cached the first thumbnail shows it for the second file. + */ + public function testStagedPreviewUrlIsUnique(): void { + $first = $this->createImages('first', 10); + $second = $this->createImages('second', 40); + + $this->drupalGet('node/add/' . $this->contentType); + $delta = $this->uploadImage($first[0]); + $first_preview = $this->getPreviewUrl(); + $this->submitForm([ + 'title[0][value]' => 'Preview', + sprintf('%s[%d][alt]', self::FIELD, $delta) => 'first', + ], 'Save'); + $node = $this->drupalGetNodeByTitle('Preview'); + $this->assertInstanceOf(NodeInterface::class, $node); + + $this->drupalGet('node/' . $node->id() . '/edit'); + $button = $this->assertSession()->elementExists('css', sprintf('input[name^="%s_"][name$="_remove_button"]', self::FIELD)); + $this->submitForm([], $button->getAttribute('name')); + $this->uploadImage($second[0]); + $second_preview = $this->getPreviewUrl(); + + $this->assertNotSame($first_preview, $second_preview, 'Two different files never share a preview URL.'); + } + + /** + * Creates three JPEG images with the same names and different content. + * + * @param string $set + * The directory name for the set. + * @param int $size + * The width and height of the first image. Each image is 10px larger. + * + * @return string[] + * The URIs, in upload order. + */ + private function createImages(string $set, int $size): array { + $directory = 'public://' . $set; + \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY); + $source = NULL; + foreach ($this->drupalGetTestFiles('image') as $file) { + if (str_ends_with($file->uri, 'image-test.jpg')) { + $source = $file->uri; + } + } + $this->assertNotNull($source); + + $uris = []; + foreach (['one', 'two', 'three'] as $index => $name) { + $image = \Drupal::service('image.factory')->get($source); + $image->resize($size + $index * 10, $size + $index * 10); + $uri = $directory . '/' . $name . '.jpg'; + $this->assertTrue($image->save($uri)); + $uris[] = $uri; + } + return $uris; + } + + /** + * Uploads an image into the empty slot of the widget on the current page. + * + * @param string $uri + * The file to upload. + * + * @return int + * The delta of the slot the file went into. + */ + private function uploadImage(string $uri): int { + $input = $this->assertSession()->elementExists('css', sprintf('input[type="file"][name^="files[%s_"]', self::FIELD)); + $name = (string) $input->getAttribute('name'); + $this->assertSame(1, preg_match('/_(\d+)\]\[\]$/', $name, $matches), 'The empty slot has a delta.'); + $this->submitForm([$name => $this->realpath($uri)], 'Upload'); + return (int) $matches[1]; + } + + /** + * Returns the preview image URL of the only staged item on the current page. + */ + private function getPreviewUrl(): string { + $images = $this->getSession()->getPage()->findAll('css', 'img[data-drupal-selector$="-preview"]'); + $this->assertCount(1, $images, 'The form shows one preview thumbnail.'); + $src = reset($images)->getAttribute('src'); + $this->assertNotEmpty($src); + return $src; + } + + /** + * Asserts the node's images match the uploaded set, in order. + * + * @param int $nid + * The node ID. + * @param string[] $sources + * The uploaded files, in upload order. + * @param string $set + * The set name, used in the alt text. + * + * @return string[] + * The URIs of the field's files, in delta order. + */ + private function assertImages(int $nid, array $sources, string $set): array { + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $storage->resetCache([$nid]); + $node = $storage->load($nid); + $this->assertInstanceOf(NodeInterface::class, $node); + $items = $node->get(self::FIELD)->getValue(); + $this->assertCount(count($sources), $items); + + $uris = []; + foreach ($items as $delta => $item) { + $file = File::load($item['target_id']); + $this->assertInstanceOf(File::class, $file); + $uri = $file->getFileUri(); + $uris[] = $uri; + $this->assertSame($set . ' ' . basename($sources[$delta]), $item['alt'], 'Delta ' . $delta . ' keeps its alt text.'); + $this->assertMatchesRegularExpression('#^public://' . date('Y-m') . '/Photo set(_\d+)?\.jpg$#', $uri, 'Delta ' . $delta . ' is at the configured path.'); + $this->assertFileExists($this->realpath($uri)); + $this->assertSame(md5_file($this->realpath($sources[$delta])), md5_file($this->realpath($uri)), 'Delta ' . $delta . ' has the content of ' . $sources[$delta] . '.'); + } + $this->assertCount(count($sources), array_unique($uris), 'Every item has its own file.'); + return $uris; + } + + /** + * Returns the local path of a stream wrapper URI. + */ + private function realpath(string $uri): string { + $path = \Drupal::service('file_system')->realpath($uri); + $this->assertIsString($path); + return $path; + } + +} diff --git a/tests/src/Kernel/StagingDirectoryCleanupTest.php b/tests/src/Kernel/StagingDirectoryCleanupTest.php new file mode 100644 index 0000000..b11ecc6 --- /dev/null +++ b/tests/src/Kernel/StagingDirectoryCleanupTest.php @@ -0,0 +1,145 @@ + + */ + protected static $modules = [ + 'system', + 'user', + 'field', + 'file', + 'entity_test', + 'filefield_paths', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + $this->installEntitySchema('file'); + $this->installEntitySchema('entity_test'); + $this->installSchema('file', ['file_usage']); + $this->installConfig(['filefield_paths']); + } + + /** + * An empty staging directory is removed with its last file. + */ + public function testEmptyStagingDirectoryIsRemoved(): void { + $file = $this->createFile('public://filefield_paths/ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertDirectoryDoesNotExist('public://filefield_paths/ffp-abc123'); + $this->assertDirectoryExists('public://filefield_paths'); + } + + /** + * A staging directory that still holds a file is kept. + */ + public function testStagingDirectoryWithFilesIsKept(): void { + $file = $this->createFile('public://filefield_paths/ffp-abc123/example.txt'); + $this->createFile('public://filefield_paths/ffp-abc123/other.txt'); + + $file->delete(); + + $this->assertFileExists('public://filefield_paths/ffp-abc123/other.txt'); + } + + /** + * A directory the module did not create is left alone, even when empty. + */ + public function testOtherDirectoriesAreKept(): void { + $file = $this->createFile('public://other/example.txt'); + + $file->delete(); + + $this->assertFileDoesNotExist('public://other/example.txt'); + $this->assertDirectoryExists('public://other'); + } + + /** + * A staging name outside every staging location proves nothing. + * + * Another module or a site can name a directory the same way. Only a + * directory inside a configured staging location belongs to this module. + */ + public function testStagingNameOutsideTheStagingLocationIsKept(): void { + $file = $this->createFile('public://other/ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertFileDoesNotExist('public://other/ffp-abc123/example.txt'); + $this->assertDirectoryExists('public://other/ffp-abc123'); + } + + /** + * A field can stage its uploads in a location of its own. + */ + public function testFieldStagingDirectoryIsRemoved(): void { + FieldStorageConfig::create([ + 'field_name' => 'field_file', + 'entity_type' => 'entity_test', + 'type' => 'file', + ])->save(); + FieldConfig::create([ + 'field_name' => 'field_file', + 'entity_type' => 'entity_test', + 'bundle' => 'entity_test', + 'third_party_settings' => [ + 'filefield_paths' => ['temp_location' => 'public://custom-staging'], + ], + ])->save(); + $file = $this->createFile('public://custom-staging/ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertDirectoryDoesNotExist('public://custom-staging/ffp-abc123'); + $this->assertDirectoryExists('public://custom-staging'); + } + + /** + * Writes a file to disk and saves a temporary file entity for it. + */ + private function createFile(string $uri): File { + $file_system = $this->container->get('file_system'); + $directory = $file_system->dirname($uri); + $file_system->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY); + file_put_contents($uri, 'contents'); + $file = File::create(['uri' => $uri]); + $file->setTemporary(); + $file->save(); + return $file; + } + +}