From d913b1946ebfb31ad02e3e699274d4930d98815e Mon Sep 17 00:00:00 2001 From: Rias Date: Sun, 12 Jul 2026 14:28:14 +0200 Subject: [PATCH 1/4] Use Laravel disks for asset I/O --- resources/css/cp.css | 8 +- .../js/pages/settings/filesystems/Index.vue | 4 +- src/Asset/AssetIndexer.php | 11 +- src/Asset/Assets.php | 21 +- src/Asset/AssetsHelper.php | 4 +- src/Asset/Commands/Concerns/IndexesAssets.php | 3 +- src/Asset/Data/Volume.php | 134 ++++-------- src/Asset/Elements/Asset.php | 52 ++--- src/Asset/Policies/AssetPolicy.php | 5 +- src/Asset/Volumes.php | 1 + src/Field/Assets.php | 6 +- src/Field/LinkTypes/Asset.php | 2 +- src/Filesystem/Filesystems.php | 207 ++++++++++++++---- src/Filesystem/Filesystems/Local.php | 6 + .../Assets/TransformController.php | 10 +- .../Controllers/Assets/UploadController.php | 2 +- .../Settings/VolumesController.php | 7 +- src/Image/ImageTransformer.php | 13 +- src/Providers/FilesystemServiceProvider.php | 6 + src/Support/Facades/Filesystems.php | 2 +- tests/Feature/Asset/AssetIndexerTest.php | 29 ++- .../Asset/VolumeFilesystemResolutionTest.php | 2 +- tests/Feature/Filesystem/FilesystemsTest.php | 47 ++++ .../Assets/TransformControllerTest.php | 23 ++ .../Settings/FilesystemsControllerTest.php | 25 ++- tests/Feature/Image/ImageTransformerTest.php | 72 ++++++ yii2-adapter/legacy/base/BaseFsInterface.php | 5 +- yii2-adapter/legacy/base/Fs.php | 5 + yii2-adapter/legacy/base/FsTrait.php | 5 + yii2-adapter/legacy/base/LocalFsInterface.php | 3 + .../fs/bridge/LegacyFsFlysystemAdapter.php | 30 ++- .../fs/bridge/LegacyFsPathPrefixedAdapter.php | 16 ++ .../Filesystem/FilesystemCompatibility.php | 87 ++------ .../FilesystemCompatibilityTest.php | 99 +++++++-- 34 files changed, 626 insertions(+), 326 deletions(-) create mode 100644 yii2-adapter/legacy/fs/bridge/LegacyFsPathPrefixedAdapter.php diff --git a/resources/css/cp.css b/resources/css/cp.css index 5c1d450c1a7..bf34ab749ce 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -154,8 +154,8 @@ Form controls overflow: clip; } -/** -Select - */ -.cp-form-control--select { +.selectize-dropdown.cp-form-control { + position: absolute; + min-height: 0; + overflow: visible; } diff --git a/resources/js/pages/settings/filesystems/Index.vue b/resources/js/pages/settings/filesystems/Index.vue index d10722801cf..6dcd1bf09e7 100644 --- a/resources/js/pages/settings/filesystems/Index.vue +++ b/resources/js/pages/settings/filesystems/Index.vue @@ -31,7 +31,7 @@ } const props = defineProps<{ - filesystems: Array; + filesystems: {data: Array}; readOnly: boolean; }>(); @@ -85,7 +85,7 @@ ]); const table = useVueTable({ get data() { - return props.filesystems; + return props.filesystems.data; }, get columns() { return columns.value; diff --git a/src/Asset/AssetIndexer.php b/src/Asset/AssetIndexer.php index 20345cb5f17..eac9b305ca4 100644 --- a/src/Asset/AssetIndexer.php +++ b/src/Asset/AssetIndexer.php @@ -66,8 +66,6 @@ public function getIndexListOnVolume(Volume $volume, string $directory = ''): Ge return; } - $fsSubpath = $volume->getSubpath(); - try { foreach ($fileList as $listing) { if (! $listing instanceof StorageAttributes) { @@ -92,7 +90,7 @@ public function getIndexListOnVolume(Volume $volume, string $directory = ''): Ge 'fileSize' => ! $listing->isDir() && method_exists($listing, 'fileSize') ? $listing->fileSize() : null, ]); - $path = $listing->getAdjustedUri($fsSubpath); + $path = $listing->getUri(); $segments = preg_split('/\\\\|\//', $path); $lastSegmentIndex = count($segments) - 1; @@ -211,7 +209,6 @@ public function createIndexingSession( public function storeIndexList(Generator $indexList, int $sessionId, Volume $volume): int { $values = []; - $fsSubpath = $volume->getSubpath(); $now = now(); /** @var FsListing $volumeListing */ @@ -226,7 +223,7 @@ public function storeIndexList(Generator $indexList, int $sessionId, Volume $vol $values[] = [ 'volumeId' => $volume->id, 'sessionId' => $sessionId, - 'uri' => $volumeListing->getAdjustedUri($fsSubpath), + 'uri' => $volumeListing->getUri(), 'size' => $volumeListing->getFileSize(), 'timestamp' => $timestamp, 'isDir' => $volumeListing->getIsDir(), @@ -513,7 +510,7 @@ public function indexFileByListing( $indexEntry = new AssetIndexEntry([ 'volumeId' => $volume->id, 'sessionId' => $sessionId, - 'uri' => $listing->getAdjustedUri($volume->getSubpath()), + 'uri' => $listing->getUri(), 'size' => $listing->getFileSize(), 'timestamp' => $listing->getDateModified(), 'isDir' => $listing->getIsDir(), @@ -541,7 +538,7 @@ public function indexFolderByListing( $indexEntry = new AssetIndexEntry([ 'volumeId' => $volume->id, 'sessionId' => $sessionId, - 'uri' => $listing->getAdjustedUri($volume->getSubpath()), + 'uri' => $listing->getUri(), 'size' => $listing->getFileSize(), 'timestamp' => $listing->getDateModified(), 'isDir' => $listing->getIsDir(), diff --git a/src/Asset/Assets.php b/src/Asset/Assets.php index 69535d33937..94d5b0a1939 100644 --- a/src/Asset/Assets.php +++ b/src/Asset/Assets.php @@ -25,13 +25,13 @@ use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\Queries\AssetQuery; use CraftCms\Cms\Filesystem\Contracts\FsInterface; +use CraftCms\Cms\Filesystem\Filesystems as FilesystemsService; use CraftCms\Cms\Filesystem\Filesystems\Temp; use CraftCms\Cms\Image\Data\ImageTransform; use CraftCms\Cms\Image\FallbackTransformer; use CraftCms\Cms\Image\ImageHelper; use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Facades\Filesystems; -use CraftCms\Cms\Support\Facades\Path; use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Typecast; @@ -40,7 +40,6 @@ use Illuminate\Container\Attributes\Singleton; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; use InvalidArgumentException; use RuntimeException; use Throwable; @@ -184,7 +183,7 @@ public function getImagePreviewUrl(Asset $asset, int $maxWidth, int $maxHeight): if ( ! $isWebSafe || - ! $asset->getVolume()->getFs()->hasUrls || + ! $asset->getVolume()->sourceHasUrls() || $originalWidth > $width || $originalHeight > $height ) { @@ -309,7 +308,9 @@ public function getTempAssetUploadFs(): FsInterface $handle = Env::parse(Cms::config()->tempAssetUploadFs); if (! $handle) { - return new Temp; + return new Temp([ + 'handle' => 'disk:'.FilesystemsService::TEMP_ASSET_DISK, + ]); } return Filesystems::resolve($handle) @@ -323,17 +324,7 @@ public function getTempAssetUploadDisk(): FilesystemAdapter { $handle = Env::parse(Cms::config()->tempAssetUploadFs); - if (! $handle) { - return Storage::build([ // @phpstan-ignore return.type - 'driver' => 'local', - 'root' => Path::tempAssetUploads(), - ]); - } - - return Storage::disk( - Filesystems::resolveDiskName($handle) - ?? throw new RuntimeException("The tempAssetUploadFs config setting is set to an invalid filesystem value: $handle") - ); + return Filesystems::disk($handle ?: 'disk:'.FilesystemsService::TEMP_ASSET_DISK); } public function createTempAssetQuery(): AssetQuery diff --git a/src/Asset/AssetsHelper.php b/src/Asset/AssetsHelper.php index e97214f6fed..0efbc395603 100644 --- a/src/Asset/AssetsHelper.php +++ b/src/Asset/AssetsHelper.php @@ -145,11 +145,11 @@ public static function revUrl(string $url, Asset $asset, ?DateTimeInterface $dat $baseUrls = Collection::make(); $volume = $asset->getVolume(); - if ($volume->getFs()->hasUrls) { + if ($volume->sourceHasUrls()) { $baseUrls->push(self::diskBaseUrl($volume->sourceDisk())); } - if ($volume->getTransformFs()->hasUrls) { + if ($volume->transformHasUrls()) { $baseUrls->push(self::diskBaseUrl($volume->transformDisk())); } diff --git a/src/Asset/Commands/Concerns/IndexesAssets.php b/src/Asset/Commands/Concerns/IndexesAssets.php index 9db1e9b9f52..83440959ab2 100644 --- a/src/Asset/Commands/Concerns/IndexesAssets.php +++ b/src/Asset/Commands/Concerns/IndexesAssets.php @@ -144,7 +144,6 @@ private function processVolume( $this->components->twoColumnDetail("Indexing assets in {$volume->name}"); $fileList = AssetIndexer::getIndexListOnVolume($volume, $path); - $fsSubpath = $volume->getSubpath(); /** @var Collection $missingRecords */ $missingRecords = Collection::make(); @@ -153,7 +152,7 @@ private function processVolume( /** @var FsListing $item */ foreach ($fileList as $index => $item) { $count = $index + 1; - $description = "#{$count}: {$item->getAdjustedUri($fsSubpath)}".($item->getIsDir() ? '/' : '').''; + $description = "#{$count}: {$item->getUri()}".($item->getIsDir() ? '/' : '').''; if ($count < $startAt) { $this->components->twoColumnDetail($description, 'SKIPPED'); diff --git a/src/Asset/Data/Volume.php b/src/Asset/Data/Volume.php index 2a204c46969..a16f9fc2a4e 100644 --- a/src/Asset/Data/Volume.php +++ b/src/Asset/Data/Volume.php @@ -13,7 +13,6 @@ use CraftCms\Cms\FieldLayout\Contracts\FieldLayoutProviderInterface; use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Filesystems as FilesystemsService; -use CraftCms\Cms\Filesystem\Filesystems\DiskFilesystem; use CraftCms\Cms\Filesystem\Filesystems\MissingFs; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Env; @@ -22,7 +21,6 @@ use CraftCms\RulesetValidation\Attributes\Ruleset; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; use Override; use RuntimeException; @@ -40,8 +38,6 @@ class Volume extends Component implements CpEditable, FieldLayoutProviderInterfa { use HasFieldLayout; - public const string STORAGE_FS_PREFIX = 'fs:'; - public const string STORAGE_DISK_PREFIX = 'disk:'; public ?int $id = null; @@ -104,6 +100,8 @@ class Volume extends Component implements CpEditable, FieldLayoutProviderInterfa private ?string $_transformFsHandle = null; + private bool $_temporary = false; + public function __construct(array|object $config = []) { if (is_object($config)) { @@ -180,23 +178,27 @@ public function resolveStorageTargetKey(?string $value, bool $parse = true): ?st if (str_starts_with($value, self::STORAGE_DISK_PREFIX)) { $diskName = substr($value, strlen(self::STORAGE_DISK_PREFIX)); - if ($diskName === '' || ! $this->diskExists($diskName) || $this->isInternalDiskName($diskName)) { + if ( + $diskName === '' || + ! $this->diskExists($diskName) || + ($this->isInternalDiskName($diskName) && ! $this->_temporary) + ) { return null; } - return self::STORAGE_DISK_PREFIX.$diskName; + return $diskName; } if (Filesystems::getFilesystemByHandle($value)) { - return self::STORAGE_FS_PREFIX.$value; + return Filesystems::toDiskName($value); } if ($this->diskExists($value)) { - if ($this->isInternalDiskName($value)) { + if ($this->isInternalDiskName($value) && ! $this->_temporary) { return null; } - return self::STORAGE_DISK_PREFIX.$value; + return $value; } return null; @@ -223,44 +225,6 @@ private function normalizeStorageHandle(?string $value): ?string return $value; } - private function filesystemFromTargetKey(string $target): ?FsInterface - { - if (str_starts_with($target, self::STORAGE_FS_PREFIX)) { - $handle = substr($target, strlen(self::STORAGE_FS_PREFIX)); - - return Filesystems::getFilesystemByHandle($handle); - } - - if (str_starts_with($target, self::STORAGE_DISK_PREFIX)) { - $diskName = substr($target, strlen(self::STORAGE_DISK_PREFIX)); - if ($diskName === '' || ! $this->diskExists($diskName)) { - return null; - } - - return $this->diskFilesystem($diskName); - } - - return null; - } - - private function diskFilesystem(string $diskName): DiskFilesystem - { - $url = config("filesystems.disks.$diskName.url"); - if (is_string($url) && $url !== '') { - $url = rtrim($url, '/'); - } else { - $url = null; - } - - return new DiskFilesystem([ - 'disk' => $diskName, - 'name' => $diskName, - 'handle' => self::STORAGE_DISK_PREFIX.$diskName, - 'hasUrls' => $url !== null, - 'url' => $url, - ]); - } - private function diskExists(string $diskName): bool { return app(FilesystemsService::class)->diskExists($diskName); @@ -337,8 +301,7 @@ public function getFs(): FsInterface throw new RuntimeException('Volume is missing its filesystem handle.'); } - $target = $this->resolveStorageTargetKey($this->_fsHandle); - $fs = $target !== null ? $this->filesystemFromTargetKey($target) : null; + $fs = Filesystems::resolve($this->_fsHandle); if (! $fs) { Log::error("Invalid filesystem handle: $this->_fsHandle for the $this->name volume."); @@ -375,8 +338,7 @@ public function getTransformFs(): FsInterface return $this->getFs(); } - $target = $this->resolveStorageTargetKey($this->_transformFsHandle); - $fs = $target !== null ? $this->filesystemFromTargetKey($target) : null; + $fs = Filesystems::resolve($this->_transformFsHandle); if (! $fs) { Log::error("Invalid transform filesystem handle: $this->_transformFsHandle for the $this->name volume."); @@ -481,9 +443,9 @@ public function setTransformSubpath(?string $subpath): void public function sourceDisk(): FilesystemAdapter { - return $this->storageDiskFor( + return Filesystems::disk( $this->diskNameForOperations(), - $this->diskPrefix(), + $this->_subpath, ); } @@ -491,22 +453,36 @@ public function transformDisk(): FilesystemAdapter { $hasTransformFs = (bool) $this->getTransformFsHandle(false); - return $this->storageDiskFor( + return Filesystems::disk( $this->diskNameForOperations($hasTransformFs ? $this->_transformFsHandle : $this->_fsHandle), - $this->diskPrefix($this->_transformSubpath), + $this->_transformSubpath, ); } - private function diskPrefix(?string $subpath = null): ?string + public function sourceHasUrls(): bool { - $subpath = Env::parse($subpath ?? $this->_subpath) ?? ''; - $subpath = trim($subpath, '/'); + return $this->getFs()->hasUrls; + } - if ($subpath === '') { - return null; - } + public function transformHasUrls(): bool + { + return $this->getTransformFs()->hasUrls; + } - return $subpath; + /** @return class-string */ + public function sourceFilesystemType(): string + { + return $this->getFs()::class; + } + + public function isTemporary(): bool + { + return $this->_temporary; + } + + public function markAsTemporary(): void + { + $this->_temporary = true; } private function parseStorageHandle(?string $handle, bool $parse): ?string @@ -525,38 +501,6 @@ private function diskNameForOperations(?string $handle = null): string throw new RuntimeException('Volume is missing or has an invalid filesystem handle.'); } - if (str_starts_with($target, self::STORAGE_DISK_PREFIX)) { - return substr($target, strlen(self::STORAGE_DISK_PREFIX)); - } - - if (str_starts_with($target, self::STORAGE_FS_PREFIX)) { - $handle = substr($target, strlen(self::STORAGE_FS_PREFIX)); - if ($handle === '') { - throw new RuntimeException('Volume has an invalid filesystem handle.'); - } - - return Filesystems::toDiskName($handle); - } - - throw new RuntimeException('Volume has an invalid filesystem handle.'); - } - - private function storageDiskFor(string $diskName, ?string $prefix): FilesystemAdapter - { - if ($prefix === null) { - return Storage::disk($diskName); - } - - $disk = Storage::build([ - 'driver' => 'scoped', - 'disk' => $diskName, - 'prefix' => $prefix, - ]); - - if (! $disk instanceof FilesystemAdapter) { - throw new RuntimeException('Invalid filesystem disk configuration.'); - } - - return $disk; + return $target; } } diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index 754eaf18be2..a1465f3aa40 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -67,6 +67,7 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Assets as AssetsService; use CraftCms\Cms\Support\Facades\ElementSources; +use CraftCms\Cms\Support\Facades\Filesystems; use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\I18N; @@ -484,7 +485,7 @@ protected static function defineSources(string $context): array ! app()->runningInConsole() ) { $temporaryUploadFolder = AssetsService::getUserTemporaryUploadFolder(); - $temporaryUploadFs = AssetsService::getTempAssetUploadFs(); + $temporaryUploadVolume = $temporaryUploadFolder->getVolume(); $sources[] = [ 'key' => 'temp', 'label' => t('Temporary Uploads'), @@ -497,7 +498,7 @@ protected static function defineSources(string $context): array 'can-upload' => true, 'can-move-to' => false, 'can-move-peer-files-to' => false, - 'fs-type' => $temporaryUploadFs::class, + 'fs-type' => $temporaryUploadVolume->sourceFilesystemType(), ], ]; } @@ -570,8 +571,7 @@ protected static function defineActions(string $source): array // Only match the first folder ID - ignore nested folders if (isset($volume)) { - $fs = $volume->getFs(); - $isTemp = AssetsHelper::isTempUploadFs($fs); + $isTemp = $volume->isTemporary(); $actions[] = [ 'type' => PreviewAsset::class, @@ -588,7 +588,7 @@ protected static function defineActions(string $source): array } // Copy URL - if ($fs->hasUrls) { + if ($volume->sourceHasUrls()) { $actions[] = CopyUrl::class; } @@ -931,7 +931,7 @@ private static function _includeFoldersInIndexElements(AssetQuery $assetQuery, ? } } - if (AssetsHelper::isTempUploadFs($queryFolder->getFs())) { + if ($queryFolder->getVolume()->isTemporary()) { return false; } @@ -1050,7 +1050,6 @@ private static function _applyFolderQuerySearchCondition(Builder $query, SearchQ private static function _assembleSourceInfoForFolder(VolumeFolder $folder, ?User $user = null): array { $volume = $folder->getVolume(); - $fs = $volume->getFs(); if (! $folder->parentId) { $volumeHandle = $volume->handle ?? false; } else { @@ -1074,7 +1073,7 @@ private static function _assembleSourceInfoForFolder(VolumeFolder $folder, ?User 'folder-id' => $folder->id, 'can-upload' => $folder->volumeId === null || $canUpload, 'can-move-to' => $canMoveTo, - 'fs-type' => $fs::class, + 'fs-type' => $volume->sourceFilesystemType(), ], ]; @@ -1279,7 +1278,7 @@ protected function cpEditUrl(): ?string } $volume = $this->getVolume(); - if (AssetsHelper::isTempUploadFs($volume->getFs())) { + if ($volume->isTemporary()) { return null; } @@ -1507,7 +1506,7 @@ protected function safeActionMenuItems(): array InputNamespace::namespaceId($replaceId), InputNamespace::get(), $this->id, - $this->getVolume()->getFs()::class, + $this->getVolume()->sourceFilesystemType(), t('Dimensions'), ]); } @@ -1570,15 +1569,16 @@ protected function safeActionMenuItems(): array ['volumeId' => $this->volumeId], ]); - // Filesystem settings - $fsEditId = sprintf('edit-fs-%s', mt_rand()); - $items[] = [ - 'id' => $fsEditId, - 'icon' => 'gear', - 'label' => t('Filesystem settings'), - ]; + $fsHandle = $this->getVolume()->getFsHandle(); + if (is_string($fsHandle) && ! str_starts_with($fsHandle, Volume::STORAGE_DISK_PREFIX) && Filesystems::getFilesystemByHandle($fsHandle)) { + $fsEditId = sprintf('edit-fs-%s', mt_rand()); + $items[] = [ + 'id' => $fsEditId, + 'icon' => 'gear', + 'label' => t('Filesystem settings'), + ]; - HtmlStack::jsWithVars(fn ($id, $params) => << << { $('#' + $id).on('activate', function() { const params = $params; @@ -1586,9 +1586,10 @@ protected function safeActionMenuItems(): array }); })(); JS, [ - InputNamespace::namespaceId($fsEditId), - ['handle' => $this->getVolume()->getFs()->handle], - ]); + InputNamespace::namespaceId($fsEditId), + ['handle' => $fsHandle], + ]); + } } return $items; @@ -1994,8 +1995,7 @@ private function _url(mixed $transform = null, ?bool $immediately = null): ?stri return $url; } - $fs = $volume->getFs(); - if (! $fs->hasUrls || AssetsHelper::isTempUploadFs($fs)) { + if (! $volume->sourceHasUrls() || $volume->isTemporary()) { return null; } @@ -2751,7 +2751,7 @@ protected function metadata(): array private function locationHtml(): string { $volume = $this->getVolume(); - $isTemp = AssetsHelper::isTempUploadFs($volume->getFs()); + $isTemp = $volume->isTemporary(); if (! $isTemp) { $uri = "assets/$volume->handle"; @@ -2886,7 +2886,7 @@ public function beforeSave(bool $isNew): bool // Set the field layout $volume = Folders::getFolderById($folderId)->getVolume(); - if (! AssetsHelper::isTempUploadFs($volume->getFs())) { + if (! $volume->isTemporary()) { $this->fieldLayoutId = $volume->fieldLayoutId; } @@ -3106,7 +3106,7 @@ protected function htmlAttributes(string $context): array $volume = $this->getVolume(); $imageEditable = $context === ElementSources::CONTEXT_INDEX && $this->getSupportsImageEditor(); - if (AssetsHelper::isTempUploadFs($volume->getFs()) || Auth::id() === $this->uploaderId) { + if ($volume->isTemporary() || Auth::id() === $this->uploaderId) { $attributes['data']['own-file'] = true; $movable = $replaceable = true; } else { diff --git a/src/Asset/Policies/AssetPolicy.php b/src/Asset/Policies/AssetPolicy.php index 623d1809b83..f48b38c2e34 100644 --- a/src/Asset/Policies/AssetPolicy.php +++ b/src/Asset/Policies/AssetPolicy.php @@ -4,7 +4,6 @@ namespace CraftCms\Cms\Asset\Policies; -use CraftCms\Cms\Asset\AssetsHelper; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Element\Policies\ElementPolicy; @@ -27,7 +26,7 @@ public function view(CraftUser $user, Asset $asset): bool return $user->can("viewPeerAssets:$volume->uid"); } - if (AssetsHelper::isTempUploadFs($volume->getFs())) { + if ($volume->isTemporary()) { return true; } @@ -54,7 +53,7 @@ public function delete(CraftUser $user, Asset $asset): bool $volume = $asset->getVolume(); - if (AssetsHelper::isTempUploadFs($volume->getFs())) { + if ($volume->isTemporary()) { return true; } diff --git a/src/Asset/Volumes.php b/src/Asset/Volumes.php index aefd9f9f21e..266810d0258 100644 --- a/src/Asset/Volumes.php +++ b/src/Asset/Volumes.php @@ -109,6 +109,7 @@ public function getTemporaryVolume(): Volume $fs = $this->assets->getTempAssetUploadFs(); $volume->setFs($fs); + $volume->markAsTemporary(); return $volume; } diff --git a/src/Field/Assets.php b/src/Field/Assets.php index 126b31570c8..62b5d1eea97 100644 --- a/src/Field/Assets.php +++ b/src/Field/Assets.php @@ -671,7 +671,7 @@ public function getInputSources(?ElementInterface $element = null): array $volume = Volumes::getVolumeByUid($volumeUid); - return $volume?->getFs() instanceof Temp; + return $volume?->isTemporary() ?? false; }) ->values() ->all(); @@ -686,13 +686,11 @@ protected function inputTemplateVariables(array|ElementQueryInterface|null $valu $variables = parent::inputTemplateVariables($value, $element); $uploadVolume = $this->_uploadVolume(); - $uploadFs = $uploadVolume?->getFs(); - $variables['fsType'] = $uploadFs::class; + $variables['fsType'] = $uploadVolume?->sourceFilesystemType(); $variables['showFolders'] = ! $this->restrictLocation || $this->allowSubfolders; $variables['canUpload'] = ( $this->allowUploads && $uploadVolume && - $uploadFs && Gate::check("saveAssets:$uploadVolume->uid") ); $variables['defaultFieldLayoutId'] = $uploadVolume->fieldLayoutId ?? null; diff --git a/src/Field/LinkTypes/Asset.php b/src/Field/LinkTypes/Asset.php index b8c0be60808..507405e0510 100644 --- a/src/Field/LinkTypes/Asset.php +++ b/src/Field/LinkTypes/Asset.php @@ -88,7 +88,7 @@ public function getSettingsHtml(): string protected function availableSourceKeys(): array { $volumes = Volumes::getAllVolumes() - ->filter(fn (Volume $volume) => $volume->getFs()->hasUrls); + ->filter(fn (Volume $volume) => $volume->sourceHasUrls()); if (! $this->showUnpermittedVolumes) { $volumes = $volumes->filter(fn (Volume $volume) => Gate::check("viewAssets:$volume->uid")); diff --git a/src/Filesystem/Filesystems.php b/src/Filesystem/Filesystems.php index 22e2c7be0d7..d172c405c5e 100644 --- a/src/Filesystem/Filesystems.php +++ b/src/Filesystem/Filesystems.php @@ -9,31 +9,37 @@ use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Events\FilesystemRenamed; use CraftCms\Cms\Filesystem\Events\FilesystemTypesResolving; +use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; +use CraftCms\Cms\Filesystem\Exceptions\InvalidSubpathException; use CraftCms\Cms\Filesystem\Filesystems\DiskFilesystem; use CraftCms\Cms\Filesystem\Filesystems\Local; use CraftCms\Cms\Filesystem\Filesystems\MissingFs; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\ProjectConfig\ProjectConfigHelper; +use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Facades\Volumes; use Illuminate\Container\Attributes\Singleton; use Illuminate\Contracts\Config\Repository as ConfigRepository; -use Illuminate\Contracts\Filesystem\Filesystem as LaravelFilesystem; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; -use Throwable; #[Singleton] class Filesystems { public const string DISK_PREFIX = 'craft-fs-'; + public const string TEMP_ASSET_DISK = 'craft-asset-temp'; + /** * Internal disk names reserved by Craft that should not be exposed as user-selectable filesystems. */ - public const array INTERNAL_DISK_NAMES = ['craft-tmp', 'rebrand']; + public const array INTERNAL_DISK_NAMES = ['craft-tmp', self::TEMP_ASSET_DISK, 'rebrand']; + + private const string GENERATED_CONFIG_KEY = '_craft'; /** * @var Collection|null @@ -119,9 +125,25 @@ public function toDiskName(string $handle): string return self::DISK_PREFIX.$handle; } - public function disk(string $handle): LaravelFilesystem + public function disk(string $reference, ?string $prefix = null): FilesystemAdapter { - return Storage::disk($this->toDiskName($handle)); + $diskName = $this->resolveDiskName($reference) + ?? throw new FilesystemException("Invalid filesystem reference: $reference"); + + $prefix = $this->resolvePrefix($prefix); + $disk = $prefix === null + ? Storage::disk($diskName) + : Storage::build([ + 'driver' => 'scoped', + 'disk' => $diskName, + 'prefix' => $prefix, + ]); + + if (! $disk instanceof FilesystemAdapter) { + throw new FilesystemException("Filesystem reference [$reference] did not resolve to a Laravel filesystem adapter."); + } + + return $disk; } /** @@ -132,14 +154,29 @@ public function syncDisks(): void $currentDisks = $this->currentDiskConfigs(); $craftDisks = $this->craftDisksFromProjectConfig(); - $manualDisks = array_filter($currentDisks, fn (mixed $v, string $k) => ! str_starts_with($k, self::DISK_PREFIX), ARRAY_FILTER_USE_BOTH); + $manualDisks = []; + foreach ($currentDisks as $diskName => $diskConfig) { + if ($this->isGeneratedDiskConfig($diskName, $diskConfig)) { + continue; + } + + if (str_starts_with($diskName, self::DISK_PREFIX)) { + throw new FilesystemException("Laravel disk [$diskName] uses Craft's reserved disk prefix."); + } + + $manualDisks[$diskName] = $diskConfig; + } $this->config->set('filesystems.disks', [ ...$manualDisks, ...$craftDisks, ]); - $staleDiskNames = array_keys(array_filter($currentDisks, fn (mixed $v, string $k) => str_starts_with($k, self::DISK_PREFIX), ARRAY_FILTER_USE_BOTH)); + $staleDiskNames = array_keys(array_filter( + $currentDisks, + fn (mixed $config, string $name): bool => $this->isGeneratedDiskConfig($name, $config), + ARRAY_FILTER_USE_BOTH, + )); $this->forgetDisks([ ...$staleDiskNames, @@ -166,7 +203,11 @@ public function registerDisk(string $handle, ?array $filesystemConfig = null): v return; } + $diskConfig = $this->generatedDiskConfig($diskConfig); $diskConfigs = $this->currentDiskConfigs(); + if (array_key_exists($diskName, $diskConfigs) && ! $this->isGeneratedDiskConfig($diskName, $diskConfigs[$diskName])) { + throw new FilesystemException("Laravel disk [$diskName] uses Craft's reserved disk prefix."); + } $diskConfigs[$diskName] = $diskConfig; $this->config->set('filesystems.disks', $diskConfigs); $this->filesystemManager->forgetDisk($diskName); @@ -186,6 +227,10 @@ public function purgeDisk(string $handle): void return; } + if (! $this->isGeneratedDiskConfig($diskName, $diskConfigs[$diskName])) { + throw new FilesystemException("Laravel disk [$diskName] uses Craft's reserved disk prefix."); + } + unset($diskConfigs[$diskName]); $this->config->set('filesystems.disks', $diskConfigs); @@ -329,25 +374,13 @@ public function diskExists(string $diskName): bool */ public function resolve(string $handle): ?FsInterface { - if (str_starts_with($handle, 'disk:')) { - $diskName = substr($handle, strlen('disk:')); - if ($diskName !== '' && $this->diskExists($diskName)) { - return new DiskFilesystem(['disk' => $diskName]); - } - - return null; - } + $target = $this->classify($handle); - $fs = $this->getFilesystemByHandle($handle); - if ($fs) { - return $fs; - } - - if ($this->diskExists($handle)) { - return new DiskFilesystem(['disk' => $handle]); - } - - return null; + return match ($target['type'] ?? null) { + 'filesystem' => $target['filesystem'], + 'disk' => $this->diskFilesystem($target['diskName']), + default => null, + }; } /** @@ -355,21 +388,9 @@ public function resolve(string $handle): ?FsInterface */ public function resolveDiskName(string $handle): ?string { - if (str_starts_with($handle, 'disk:')) { - $diskName = substr($handle, strlen('disk:')); - - return ($diskName !== '' && $this->diskExists($diskName)) ? $diskName : null; - } - - if ($this->getFilesystemByHandle($handle)) { - return $this->toDiskName($handle); - } + $target = $this->classify($handle); - if ($this->diskExists($handle)) { - return $handle; - } - - return null; + return $target['diskName'] ?? null; } public function reset(): void @@ -378,6 +399,7 @@ public function reset(): void $this->syncDisks(); } + /** @return array */ private function currentDiskConfigs(): array { $diskConfigs = $this->config->get('filesystems.disks', []); @@ -426,7 +448,7 @@ private function craftDisksFromProjectConfig(): array continue; } - $craftDisks[$this->toDiskName($handle)] = $diskConfig; + $craftDisks[$this->toDiskName($handle)] = $this->generatedDiskConfig($diskConfig); } return $craftDisks; @@ -445,11 +467,11 @@ private function resolveDiskConfig(string $handle): ?array return null; } - try { - return $filesystem->getDiskConfig(); - } catch (Throwable) { + if ($filesystem instanceof MissingFs) { return null; } + + return $this->validateDiskConfig($handle, $filesystem->getDiskConfig()); } /** @@ -466,10 +488,103 @@ private function resolveDiskConfigFromArray(string $handle, array $filesystemCon $filesystemConfig['handle'] = $handle; $filesystemConfig['settings'] = ProjectConfigHelper::unpackAssociativeArrays($filesystemConfig['settings'] ?? []); - try { - return $this->createFilesystem($filesystemConfig)->getDiskConfig(); - } catch (Throwable) { + $filesystem = $this->createFilesystem($filesystemConfig); + if ($filesystem instanceof MissingFs) { + return null; + } + + return $this->validateDiskConfig($handle, $filesystem->getDiskConfig()); + } + + /** @return array{type:'filesystem'|'disk',diskName?:string,filesystem?:FsInterface}|null */ + private function classify(string $reference): ?array + { + $reference = Env::parse($reference); + if (! is_string($reference) || $reference === '') { + return null; + } + + if (str_starts_with($reference, 'disk:')) { + $diskName = substr($reference, strlen('disk:')); + + return $diskName !== '' && $this->diskExists($diskName) + ? ['type' => 'disk', 'diskName' => $diskName] + : null; + } + + $filesystem = $this->getFilesystemByHandle($reference); + if ($filesystem !== null) { + $target = [ + 'type' => 'filesystem', + 'filesystem' => $filesystem, + ]; + + if (! $filesystem instanceof MissingFs) { + $target['diskName'] = $this->toDiskName($reference); + } + + return $target; + } + + return $this->diskExists($reference) + ? ['type' => 'disk', 'diskName' => $reference] + : null; + } + + private function diskFilesystem(string $diskName): DiskFilesystem + { + $url = config("filesystems.disks.$diskName.url"); + $hasUrls = is_string($url) && $url !== ''; + + return new DiskFilesystem([ + 'disk' => $diskName, + 'hasUrls' => $hasUrls, + 'url' => $hasUrls ? rtrim($url, '/') : null, + ]); + } + + private function resolvePrefix(?string $prefix): ?string + { + if ($prefix === null || $prefix === '') { return null; } + + $resolved = Env::parse($prefix); + if (! is_string($resolved) || $resolved === '') { + throw new InvalidSubpathException($prefix); + } + + $resolved = trim($resolved, '/'); + + return $resolved !== '' ? $resolved : null; + } + + private function generatedDiskConfig(array $config): array + { + $config[self::GENERATED_CONFIG_KEY] = true; + + return $config; + } + + private function isGeneratedDiskConfig(string $name, mixed $config): bool + { + if (! str_starts_with($name, self::DISK_PREFIX) || ! is_array($config)) { + return false; + } + + if (($config[self::GENERATED_CONFIG_KEY] ?? false) === true) { + return true; + } + + return ($config['driver'] ?? null) === 'craft-fs-bridge'; + } + + private function validateDiskConfig(string $handle, array $config): array + { + if (! is_string($config['driver'] ?? null) || $config['driver'] === '') { + throw new FilesystemException("Filesystem [$handle] returned an invalid Laravel disk configuration."); + } + + return $config; } } diff --git a/src/Filesystem/Filesystems/Local.php b/src/Filesystem/Filesystems/Local.php index f1d71e84fdb..fcea64ccafc 100644 --- a/src/Filesystem/Filesystems/Local.php +++ b/src/Filesystem/Filesystems/Local.php @@ -94,6 +94,12 @@ public function attributeLabels(): array ]); } + #[Override] + public function settingsAttributes(): array + { + return array_values(array_diff(parent::settingsAttributes(), ['rootPath', 'settingsHtml'])); + } + #[Override] public function getRules(): array { diff --git a/src/Http/Controllers/Assets/TransformController.php b/src/Http/Controllers/Assets/TransformController.php index 0b039bd748f..010b725d96e 100644 --- a/src/Http/Controllers/Assets/TransformController.php +++ b/src/Http/Controllers/Assets/TransformController.php @@ -91,13 +91,9 @@ public function generateFallback(Request $request): Response $useOriginal = $transformString === 'original'; if ($useOriginal) { $volume = $asset->getVolume(); - if ($volume->sourceDisk() instanceof LocalFilesystemAdapter) { - $path = sprintf( - '%s/%s/%s', - rtrim($volume->sourceDisk()->path(''), '/'), - rtrim($volume->getSubpath(), '/'), - $asset->getPath() - ); + $sourceDisk = $volume->sourceDisk(); + if ($sourceDisk instanceof LocalFilesystemAdapter) { + $path = $sourceDisk->path($asset->getPath()); return response()->file($path, [ 'Content-Disposition' => 'inline; filename="'.$asset->getFilename().'"', diff --git a/src/Http/Controllers/Assets/UploadController.php b/src/Http/Controllers/Assets/UploadController.php index 4ac71ee1b17..52083b13efe 100644 --- a/src/Http/Controllers/Assets/UploadController.php +++ b/src/Http/Controllers/Assets/UploadController.php @@ -160,7 +160,7 @@ public function upload(Request $request): Response 'filename' => $asset->conflictingFilename, 'conflictingAssetId' => $conflictingAsset->id ?? null, 'suggestedFilename' => $asset->suggestedFilename, - 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->getFs()->hasUrls) ? $conflictingAsset->getUrl() : null, + 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->sourceHasUrls()) ? $conflictingAsset->getUrl() : null, 'url' => $url, ]); } diff --git a/src/Http/Controllers/Settings/VolumesController.php b/src/Http/Controllers/Settings/VolumesController.php index ec2b7d3f3fa..24212969d34 100644 --- a/src/Http/Controllers/Settings/VolumesController.php +++ b/src/Http/Controllers/Settings/VolumesController.php @@ -16,6 +16,7 @@ use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Filesystems; use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Url; use Illuminate\Http\Request; @@ -243,10 +244,6 @@ private function fsOptionTargetKey(mixed $value): ?string return null; } - if (str_starts_with($value, 'disk:')) { - return $value; - } - - return "fs:{$value}"; + return Filesystems::resolveDiskName($value); } } diff --git a/src/Image/ImageTransformer.php b/src/Image/ImageTransformer.php index 162669aec78..77efe09530e 100644 --- a/src/Image/ImageTransformer.php +++ b/src/Image/ImageTransformer.php @@ -55,7 +55,7 @@ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bo $mimeType = $asset->getMimeType(); $generalConfig = Cms::config(); - if (! $asset->getVolume()->getFs()->hasUrls) { + if (! $asset->getVolume()->transformHasUrls()) { throw new NotSupportedException('The asset’s volume’s transform filesystem doesn’t have URLs.'); } @@ -179,12 +179,14 @@ public function invalidateAssetTransforms(Asset $asset): void public function deleteImageTransformFile(Asset $asset, ImageTransformIndex $transformIndex): void { - $path = $this->getTransformBasePath($asset).$this->getTransformSubpath($asset, $transformIndex); + $diskPath = $this->getTransformBasePath($asset).$this->getTransformSubpath($asset, $transformIndex); + $subPath = Str::chopEnd($asset->getVolume()->getTransformSubpath(), '/'); + $path = ($subPath ? $subPath.DIRECTORY_SEPARATOR : '').$diskPath; event(new DeletingTransformedImage(asset: $asset, path: $path)); try { - $asset->getVolume()->transformDisk()->delete($path); + $asset->getVolume()->transformDisk()->delete($diskPath); } catch (RuntimeException|NotSupportedException) { // NBD } @@ -640,10 +642,7 @@ public function cancelImageEditing(): string private function getTransformBasePath(Asset $asset): string { - $subPath = $asset->getVolume()->getTransformSubpath(); - $subPath = Str::chopEnd($subPath, '/'); - - return ($subPath ? $subPath.DIRECTORY_SEPARATOR : '').$asset->folderPath; + return $asset->folderPath ?? ''; } private function deleteTransformIndexDataByAssetId(int $assetId): void diff --git a/src/Providers/FilesystemServiceProvider.php b/src/Providers/FilesystemServiceProvider.php index 3a5bfbf03d2..d9f33fed77f 100644 --- a/src/Providers/FilesystemServiceProvider.php +++ b/src/Providers/FilesystemServiceProvider.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Providers; use CraftCms\Cms\Filesystem\Filesystems; +use CraftCms\Cms\Support\Facades\Path; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Support\ServiceProvider; @@ -19,6 +20,11 @@ public function boot(): void 'root' => app()->isEphemeral() ? '/tmp' : storage_path('app/temp'), ]); + $config->set('filesystems.disks.'.Filesystems::TEMP_ASSET_DISK, [ + 'driver' => 'local', + 'root' => Path::tempAssetUploads(), + ]); + $this->app->booted(fn () => app(Filesystems::class)->syncDisks()); } } diff --git a/src/Support/Facades/Filesystems.php b/src/Support/Facades/Filesystems.php index 8e49b721931..36ad0b6b08e 100644 --- a/src/Support/Facades/Filesystems.php +++ b/src/Support/Facades/Filesystems.php @@ -13,7 +13,7 @@ * @method static \Illuminate\Support\Collection getAllFilesystems() * @method static \CraftCms\Cms\Filesystem\Contracts\FsInterface|null getFilesystemByHandle(string $handle) * @method static string toDiskName(string $handle) - * @method static \Illuminate\Contracts\Filesystem\Filesystem disk(string $handle) + * @method static \Illuminate\Filesystem\FilesystemAdapter disk(string $reference, string|null $prefix = null) * @method static void syncDisks() * @method static void registerDisk(string $handle, array|null $filesystemConfig = null) * @method static void purgeDisk(string $handle) diff --git a/tests/Feature/Asset/AssetIndexerTest.php b/tests/Feature/Asset/AssetIndexerTest.php index 7bc48acf90d..fd1db0fd170 100644 --- a/tests/Feature/Asset/AssetIndexerTest.php +++ b/tests/Feature/Asset/AssetIndexerTest.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Filesystem\Data\FsListing; use CraftCms\Cms\Support\Facades\AssetIndexer as AssetIndexerFacade; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; beforeEach(function () { config()->set('filesystems.disks.test-disk', [ @@ -333,6 +334,27 @@ expect($session->processIfRootEmpty)->toBeTrue(); }); +it('stores scoped disk listings as volume-relative paths', function () { + $volume = createIndexerTestVolume(['subpath' => 'assets']); + $volumeData = resolveIndexerVolumeData($volume); + Storage::disk('test-disk')->deleteDirectory('assets'); + + try { + $volumeData->sourceDisk()->put('photo.txt', 'content'); + + $session = $this->indexer->startIndexingSession( + volumes: [$volumeData->id], + cacheRemoteImages: false, + listEmptyFolders: false, + ); + + expect(AssetIndexData::where('sessionId', $session->id)->pluck('uri')) + ->toContain('photo.txt'); + } finally { + Storage::disk('test-disk')->deleteDirectory('assets'); + } +}); + it('can get index list on volume', function () { $volume = createIndexerTestVolume(); $volumeData = resolveIndexerVolumeData($volume); @@ -374,9 +396,12 @@ // --- Helper functions --- -function createIndexerTestVolume(): Volume +function createIndexerTestVolume(array $attributes = []): Volume { - return Volume::factory()->create(['fs' => 'disk:test-disk']); + return Volume::factory()->create([ + 'fs' => 'disk:test-disk', + ...$attributes, + ]); } function resolveIndexerVolumeData(Volume $volume): VolumeData diff --git a/tests/Feature/Asset/VolumeFilesystemResolutionTest.php b/tests/Feature/Asset/VolumeFilesystemResolutionTest.php index 048bfe95765..d2811e5f3e2 100644 --- a/tests/Feature/Asset/VolumeFilesystemResolutionTest.php +++ b/tests/Feature/Asset/VolumeFilesystemResolutionTest.php @@ -261,7 +261,7 @@ expect($volume->getFsHandle(false))->toBe('disk:macro-disk') ->and($volume->getTransformFsHandle(false))->toBe('disk:macro-disk') - ->and($volume->getResolvedFsTarget())->toBe('disk:macro-disk') + ->and($volume->getResolvedFsTarget())->toBe('macro-disk') ->and($volume->getSubpath())->toBe('initial/') ->and($volume->getFs())->toBeInstanceOf(DiskFilesystem::class); diff --git a/tests/Feature/Filesystem/FilesystemsTest.php b/tests/Feature/Filesystem/FilesystemsTest.php index fe54a04ffe2..0d03030b199 100644 --- a/tests/Feature/Filesystem/FilesystemsTest.php +++ b/tests/Feature/Filesystem/FilesystemsTest.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Events\FilesystemRenamed; use CraftCms\Cms\Filesystem\Events\FilesystemTypesResolving; +use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; use CraftCms\Cms\Filesystem\Filesystems; use CraftCms\Cms\Filesystem\Filesystems\DiskFilesystem; use CraftCms\Cms\Filesystem\Filesystems\Local; @@ -17,6 +18,7 @@ use CraftCms\Cms\Support\Facades\Filesystems as FilesystemsFacade; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Storage; beforeEach(function () { $this->service = app(Filesystems::class); @@ -312,6 +314,42 @@ ->and(config('filesystems.disks.'.Filesystems::DISK_PREFIX.'stale-handle'))->toBeNull(); }); +it('scopes disk operations to an environment-backed prefix', function () { + config()->set('filesystems.disks.scoped-manual', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/fs-service/scoped-manual'), + ]); + putenv('CRAFT_TEST_FS_PREFIX=nested'); + Storage::disk('scoped-manual')->deleteDirectory('nested'); + + try { + $disk = $this->service->disk('disk:scoped-manual', '$CRAFT_TEST_FS_PREFIX'); + $disk->put('file.txt', 'contents'); + + expect(Storage::disk('scoped-manual')->get('nested/file.txt'))->toBe('contents'); + } finally { + putenv('CRAFT_TEST_FS_PREFIX'); + Storage::disk('scoped-manual')->deleteDirectory('nested'); + } +}); + +it('fails when a manual disk uses the generated disk prefix', function () { + config()->set('filesystems.disks.craft-fs-manual', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/fs-service/collision'), + ]); + + $this->service->syncDisks(); +})->throws(FilesystemException::class); + +it('fails when a filesystem returns an invalid disk configuration', function () { + $this->service->registerDisk('invalid-disk', [ + 'name' => 'Invalid Disk', + 'type' => InvalidDiskFilesystem::class, + 'settings' => [], + ]); +})->throws(FilesystemException::class); + function createServiceLocalFilesystem( Filesystems $service, string $handle, @@ -341,3 +379,12 @@ function createServiceLocalFilesystem( return $filesystem; } + +class InvalidDiskFilesystem extends Local +{ + #[Override] + public function getDiskConfig(): array + { + return []; + } +} diff --git a/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php b/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php index f314a7dcb53..fafeda8624a 100644 --- a/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php +++ b/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php @@ -83,6 +83,29 @@ ->assertStatus(400); }); + it('serves originals from within the volume subpath', function () { + $volume = Volume::factory()->create([ + 'fs' => 'disk:test-disk', + 'subpath' => 'assets', + ]); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'subpath-original.jpg', + 'kind' => 'image', + ]); + $sourceDisk = $asset->getVolume()->sourceDisk(); + $sourceDisk->put($asset->getPath(), 'original-bytes'); + $transform = Crypt::encrypt($asset->id.',original'); + + $response = get(action([TransformController::class, 'generateFallback'], ['transform' => $transform])) + ->assertOk(); + + expect($response->baseResponse->getFile()->getRealPath()) + ->toBe(realpath($sourceDisk->path($asset->getPath()))); + }); + it('serves fallback transform files for valid encrypted transforms', function () { $asset = AssetModel::factory()->create([ 'volumeId' => test()->volume->id, diff --git a/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php b/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php index 68f77b4bdb2..642e7ef0970 100644 --- a/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php +++ b/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php @@ -4,8 +4,10 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Filesystem\Filesystems\Filesystem; +use CraftCms\Cms\Filesystem\Filesystems\Local; use CraftCms\Cms\Http\Controllers\Settings\FilesystemsController; use CraftCms\Cms\Support\Facades\Filesystems; +use CraftCms\Cms\Support\Html; use CraftCms\Cms\User\Elements\User; use Illuminate\Support\Facades\Auth; use Inertia\Testing\AssertableInertia; @@ -49,9 +51,21 @@ }); test('index lists all filesystems', function () { + $fs = Filesystems::createFilesystem([ + 'type' => Local::class, + 'name' => 'Indexed Filesystem', + 'handle' => 'indexedFilesystem', + 'settings' => [ + 'path' => sys_get_temp_dir().'/indexed-filesystem', + ], + ]); + Filesystems::saveFilesystem($fs); + get(action([FilesystemsController::class, 'index'])) ->assertOk() - ->assertSee(t('Filesystems')); + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('filesystems.data', 1) + ->where('filesystems.data.0.handle', 'indexedFilesystem')); }); test('index shows read-only flag when allowAdminChanges is false', function () { @@ -136,12 +150,12 @@ test('save creates filesystem with valid data', function () { $response = postJson(action([FilesystemsController::class, 'save']), [ - 'type' => 'craft\fs\Local', + 'type' => Local::class, 'name' => 'New Test Filesystem', 'handle' => 'newTestFilesystem', 'types' => [ - 'craft-fs-Local' => [ - 'path' => '@webroot/test-uploads', + Html::id(Local::class) => [ + 'path' => sys_get_temp_dir().'/test-uploads', ], ], ]); @@ -152,6 +166,9 @@ $fs = Filesystems::getFilesystemByHandle('newTestFilesystem'); expect($fs)->not()->toBeNull(); expect($fs->name)->toBe('New Test Filesystem'); + expect($fs->getSettings())->toBe([ + 'path' => sys_get_temp_dir().'/test-uploads', + ]); }); test('save ignores null transient filesystem settings', function () { diff --git a/tests/Feature/Image/ImageTransformerTest.php b/tests/Feature/Image/ImageTransformerTest.php index 8487bf1668d..44767380e0d 100644 --- a/tests/Feature/Image/ImageTransformerTest.php +++ b/tests/Feature/Image/ImageTransformerTest.php @@ -10,8 +10,11 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Image\Data\ImageTransform; use CraftCms\Cms\Image\Data\ImageTransformIndex; +use CraftCms\Cms\Image\Events\DeletingTransformedImage; use CraftCms\Cms\Image\ImageTransformer; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Storage; beforeEach(function () { config()->set('filesystems.disks.test-disk', [ @@ -144,6 +147,75 @@ ->and($storedDateIndexed)->not->toContain('+'); }); +it('uses the transform filesystem URL policy', function () { + config()->set('filesystems.disks.transform-policy-source', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/image-transformer-test/transform-policy-source'), + ]); + config()->set('filesystems.disks.transform-policy-target', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/image-transformer-test/transform-policy-target'), + 'url' => 'https://transforms.example.test', + ]); + + $volume = Volume::factory()->create([ + 'fs' => 'disk:transform-policy-source', + 'transformFs' => 'disk:transform-policy-target', + ]); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'transform-policy.jpg', + 'kind' => 'image', + 'width' => 1200, + 'height' => 800, + 'dateModified' => now()->subMinute(), + ]); + $transform = new ImageTransform([ + 'width' => 100, + 'height' => 100, + 'mode' => 'crop', + ]); + $index = $this->transformer->getTransformIndex($asset, $transform); + $path = $asset->folderPath.$index->transformString.DIRECTORY_SEPARATOR.$index->filename; + + $asset->getVolume()->transformDisk()->put($path, 'transform-bytes'); + $index->fileExists = true; + $this->transformer->storeTransformIndexData($index); + + expect($this->transformer->getTransformUrl($asset, $transform, true)) + ->toStartWith('https://transforms.example.test/'); +}); + +it('uses transform-disk-relative paths while preserving the deletion event path', function () { + $volume = Volume::factory()->create([ + 'fs' => 'disk:test-disk', + 'transformSubpath' => 'transforms', + ]); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'scoped-transform.jpg', + 'kind' => 'image', + ]); + $index = new ImageTransformIndex([ + 'assetId' => $asset->id, + 'filename' => $asset->getFilename(), + 'transformString' => '_100x100_crop_center-center_none', + ]); + $path = $asset->folderPath.$index->transformString.DIRECTORY_SEPARATOR.$index->filename; + Storage::disk('test-disk')->deleteDirectory('transforms'); + $asset->getVolume()->transformDisk()->put($path, 'transform-bytes'); + Event::fake([DeletingTransformedImage::class]); + + $this->transformer->deleteImageTransformFile($asset, $index); + + expect($asset->getVolume()->transformDisk()->exists($path))->toBeFalse(); + Event::assertDispatched(fn (DeletingTransformedImage $event): bool => $event->path === 'transforms/'.$path); +}); + it('uses the provided asset when immediately generating transforms', function () { $asset = ($this->createImageAsset)([ 'filename' => 'transform-test.txt', diff --git a/yii2-adapter/legacy/base/BaseFsInterface.php b/yii2-adapter/legacy/base/BaseFsInterface.php index c5f907a94b6..79526217a16 100644 --- a/yii2-adapter/legacy/base/BaseFsInterface.php +++ b/yii2-adapter/legacy/base/BaseFsInterface.php @@ -1,6 +1,8 @@ + * * @since 4.0.0 + * @deprecated 6.0.0 */ abstract class Fs extends Filesystem implements BaseFsInterface, FsInterface { diff --git a/yii2-adapter/legacy/base/FsTrait.php b/yii2-adapter/legacy/base/FsTrait.php index 77425a63759..b17d7777277 100644 --- a/yii2-adapter/legacy/base/FsTrait.php +++ b/yii2-adapter/legacy/base/FsTrait.php @@ -1,6 +1,8 @@ + * * @since 4.0.0 + * @deprecated 6.0.0 */ trait FsTrait // @phpstan-ignore trait.unused { /** * @var bool Whether the “Files in this filesystem have public URLs” setting should be shown. + * * @since 4.5.0 */ protected static bool $showHasUrlSetting = true; diff --git a/yii2-adapter/legacy/base/LocalFsInterface.php b/yii2-adapter/legacy/base/LocalFsInterface.php index fa321c53b63..79ba8b069f1 100644 --- a/yii2-adapter/legacy/base/LocalFsInterface.php +++ b/yii2-adapter/legacy/base/LocalFsInterface.php @@ -1,6 +1,8 @@ filesystem->getFileList($path, $deep) as $item) { if (!$item instanceof FsListing) { - continue; + throw new UnexpectedValueException(sprintf( + '%s::getFileList() must yield %s instances, %s yielded.', + $this->filesystem::class, + FsListing::class, + get_debug_type($item), + )); } $uri = $item->getUri(); if ($item->getIsDir()) { yield new DirectoryAttributes($uri, lastModified: $item->getDateModified()); + continue; } @@ -189,6 +200,21 @@ public function listContents(string $path, bool $deep): iterable } } + public function publicUrl(string $path, Config $config): string + { + $rootUrl = $this->filesystem->getRootUrl(); + if (!is_string($rootUrl) || $rootUrl === '') { + throw UnableToGeneratePublicUrl::noGeneratorConfigured($path); + } + + return rtrim($rootUrl, '/') . '/' . ltrim($path, '/'); + } + + public function getUrl(string $path): string + { + return $this->publicUrl($path, new Config()); + } + public function move(string $source, string $destination, Config $config): void { try { diff --git a/yii2-adapter/legacy/fs/bridge/LegacyFsPathPrefixedAdapter.php b/yii2-adapter/legacy/fs/bridge/LegacyFsPathPrefixedAdapter.php new file mode 100644 index 00000000000..f10ee843638 --- /dev/null +++ b/yii2-adapter/legacy/fs/bridge/LegacyFsPathPrefixedAdapter.php @@ -0,0 +1,16 @@ +publicUrl($path, new Config()); + } +} diff --git a/yii2-adapter/src/Filesystem/FilesystemCompatibility.php b/yii2-adapter/src/Filesystem/FilesystemCompatibility.php index 76ddae0dbc0..7bec7f61e03 100644 --- a/yii2-adapter/src/Filesystem/FilesystemCompatibility.php +++ b/yii2-adapter/src/Filesystem/FilesystemCompatibility.php @@ -6,7 +6,7 @@ use craft\base\BaseFsInterface; use craft\fs\bridge\LegacyFsFlysystemAdapter; -use CraftCms\Cms\Filesystem\Contracts\FsInterface; +use craft\fs\bridge\LegacyFsPathPrefixedAdapter; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Cms\Support\Facades\Filesystems; @@ -15,99 +15,42 @@ use Illuminate\Filesystem\FilesystemManager; use InvalidArgumentException; use League\Flysystem\Filesystem as Flysystem; -use League\Flysystem\PathPrefixing\PathPrefixedAdapter; -use Throwable; readonly class FilesystemCompatibility { public function register(Application $app): void { - $filesystemWithPrefix = self::filesystemWithPrefix(...); $legacyFilesystemAdapter = self::legacyFilesystemAdapter(...); - $app->make(FilesystemManager::class)->extend(LegacyFsFlysystemAdapter::DISK_DRIVER, function($app, array $config) use ($filesystemWithPrefix, $legacyFilesystemAdapter) { + $app->make(FilesystemManager::class)->extend(LegacyFsFlysystemAdapter::DISK_DRIVER, function($app, array $config) use ($legacyFilesystemAdapter) { $handle = $config['fsHandle'] ?? null; if (!is_string($handle) || $handle === '') { throw new InvalidArgumentException('Missing `fsHandle` configuration for craft-fs-bridge disk.'); } $filesystem = Filesystems::getFilesystemByHandle($handle); - if (!$filesystem instanceof FsInterface) { - throw new InvalidArgumentException("Craft filesystem [$handle] is not registered."); + if (!$filesystem instanceof BaseFsInterface) { + throw new InvalidArgumentException("Craft filesystem [$handle] does not implement the legacy filesystem API."); } - try { - $diskConfig = $filesystem->getDiskConfig(); - if ( - ($diskConfig['driver'] ?? null) === LegacyFsFlysystemAdapter::DISK_DRIVER && - ($diskConfig['fsHandle'] ?? null) === $handle - ) { - if (!$filesystem instanceof BaseFsInterface) { - throw new InvalidArgumentException( - "Filesystem [$handle] does not provide a usable Laravel disk configuration.", - ); - } + Deprecator::log( + sprintf('filesystem-bridge:%s', $filesystem::class), + sprintf( + 'Filesystem [%s] uses the deprecated legacy filesystem API. Update `%s::getDiskConfig()` to return a native Laravel disk configuration.', + $handle, + $filesystem::class, + ), + ); - return $legacyFilesystemAdapter($filesystem, array_merge($config, $diskConfig)); - } - - $disk = $app->make(FilesystemManager::class)->build($diskConfig); - - if (!$disk instanceof LaravelFilesystemAdapter) { - throw new InvalidArgumentException("Filesystem [$handle] returned an invalid disk configuration."); - } - - return $filesystemWithPrefix($disk, $config); - } catch (Throwable $e) { - if (!$filesystem instanceof BaseFsInterface) { - throw new InvalidArgumentException( - "Filesystem [$handle] does not provide a usable Laravel disk configuration.", - previous: $e, - ); - } - - Deprecator::log( - sprintf('filesystem-bridge-fallback:%s', $filesystem::class), - sprintf( - 'Filesystem [%s] is using a legacy operation fallback. Implement `%s::getDiskConfig()` so it can be used as a native Laravel disk.', - $handle, - $filesystem::class, - ), - ); - - return $legacyFilesystemAdapter($filesystem, $config); - } + return $legacyFilesystemAdapter($filesystem, $config); }); } - private static function filesystemWithPrefix(LaravelFilesystemAdapter $disk, array $config): LaravelFilesystemAdapter - { - $prefix = $config['prefix'] ?? null; - if (!is_string($prefix) || $prefix === '') { - return $disk; - } - - $flysystemAdapter = new PathPrefixedAdapter($disk->getAdapter(), $prefix); - - return new LaravelFilesystemAdapter( - new Flysystem($flysystemAdapter, Arr::only($config, [ - 'directory_visibility', - 'disable_asserts', - 'retain_visibility', - 'temporary_url', - 'url', - 'visibility', - ])), - $flysystemAdapter, - array_merge($disk->getConfig(), $config), - ); - } - private static function legacyFilesystemAdapter(BaseFsInterface $filesystem, array $config): LaravelFilesystemAdapter { $adapter = new LegacyFsFlysystemAdapter($filesystem); $flysystemAdapter = !empty($config['prefix']) - ? new PathPrefixedAdapter($adapter, $config['prefix']) + ? new LegacyFsPathPrefixedAdapter($adapter, $config['prefix']) : $adapter; return new LaravelFilesystemAdapter( @@ -120,7 +63,7 @@ private static function legacyFilesystemAdapter(BaseFsInterface $filesystem, arr 'visibility', ])), $flysystemAdapter, - $config, + Arr::except($config, ['prefix']), ); } } diff --git a/yii2-adapter/tests-laravel/Filesystem/FilesystemCompatibilityTest.php b/yii2-adapter/tests-laravel/Filesystem/FilesystemCompatibilityTest.php index e1cf3b42f0f..adec0b750a9 100644 --- a/yii2-adapter/tests-laravel/Filesystem/FilesystemCompatibilityTest.php +++ b/yii2-adapter/tests-laravel/Filesystem/FilesystemCompatibilityTest.php @@ -8,8 +8,10 @@ use CraftCms\Cms\Filesystem\Data\FsListing; use CraftCms\Cms\Filesystem\Filesystems as FilesystemsService; use CraftCms\Cms\Filesystem\Filesystems\Filesystem; +use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Yii2Adapter\Filesystem\FilesystemCompatibility; use Illuminate\Support\Facades\Storage; +use League\Flysystem\UnableToListContents; it('resolves legacy bridge disks after Laravel rebinds the driver creator', function() { $filesystem = new LegacyFilesystemCompatibilityTestFs([ @@ -17,35 +19,96 @@ 'handle' => 'legacy-compatibility', ]); - app()->instance(FilesystemsService::class, new class($filesystem) extends FilesystemsService { - public function __construct( - private readonly FsInterface $filesystem, - ) { - } + $filesystem->register(); - public function getFilesystemByHandle(string $handle): ?FsInterface - { - return $handle === $this->filesystem->handle ? $this->filesystem : null; - } - }); + $disk = Storage::disk('legacy-compatibility'); + + expect($disk->put('legacy.txt', 'legacy'))->toBeTrue() + ->and($disk->get('legacy.txt'))->toBe('legacy'); +}); + +it('generates permanent URLs with the scoped prefix once', function() { + $filesystem = new LegacyFilesystemCompatibilityTestFs([ + 'name' => 'Legacy Compatibility', + 'handle' => 'legacy-compatibility', + 'hasUrls' => true, + 'url' => 'https://assets.example.test/root/', + ]); + $filesystem->register(); + + $disk = Storage::build([ + 'driver' => 'scoped', + 'disk' => 'legacy-compatibility', + 'prefix' => 'volume', + ]); + + expect($disk->url('images/photo.jpg')) + ->toBe('https://assets.example.test/root/volume/images/photo.jpg'); +}); + +it('fails when legacy listings contain invalid values', function() { + $filesystem = new LegacyFilesystemCompatibilityTestFs([ + 'name' => 'Legacy Compatibility', + 'handle' => 'legacy-compatibility', + ]); + $filesystem->listingValues = ['invalid']; + $filesystem->register(); - new FilesystemCompatibility()->register(app()); + expect(fn() => Storage::disk('legacy-compatibility')->listContents('', true)->toArray()) + ->toThrow(UnableToListContents::class); +}); - config()->set('filesystems.disks.legacy-compatibility', [ +it('logs one actionable deprecation per concrete legacy filesystem class', function() { + $filesystem = new LegacyFilesystemCompatibilityTestFs([ + 'name' => 'Legacy Compatibility', + 'handle' => 'legacy-compatibility', + ]); + $filesystem->register(); + config()->set('filesystems.disks.legacy-compatibility-copy', [ 'driver' => 'craft-fs-bridge', 'fsHandle' => 'legacy-compatibility', ]); - $disk = Storage::disk('legacy-compatibility'); + Storage::disk('legacy-compatibility'); + Storage::disk('legacy-compatibility-copy'); - expect($disk->put('legacy.txt', 'legacy'))->toBeTrue() - ->and($disk->get('legacy.txt'))->toBe('legacy'); + $logs = collect(Deprecator::getRequestLogs()) + ->where('key', 'filesystem-bridge:' . LegacyFilesystemCompatibilityTestFs::class) + ->values(); + + expect($logs)->toHaveCount(1) + ->and($logs[0]->message) + ->toContain('getDiskConfig()'); }); class LegacyFilesystemCompatibilityTestFs extends Filesystem implements BaseFsInterface { private array $files = []; + public array $listingValues = []; + + public function register(): void + { + app()->instance(FilesystemsService::class, new class($this) extends FilesystemsService { + public function __construct( + private readonly FsInterface $filesystem, + ) { + } + + public function getFilesystemByHandle(string $handle): ?FsInterface + { + return $handle === $this->filesystem->handle ? $this->filesystem : null; + } + }); + + new FilesystemCompatibility()->register(app()); + + config()->set('filesystems.disks.' . $this->handle, [ + 'driver' => 'craft-fs-bridge', + 'fsHandle' => $this->handle, + ]); + } + public function getDiskConfig(): array { return [ @@ -54,8 +117,12 @@ public function getDiskConfig(): array ]; } - public function getFileList(string $directory = '', bool $recursive = true): \Generator + public function getFileList(string $directory = '', bool $recursive = true): Generator { + foreach ($this->listingValues as $value) { + yield $value; + } + foreach ($this->files as $path => $contents) { yield new FsListing([ 'dirname' => dirname($path) === '.' ? '' : dirname($path), From 95dfed1707f1f508f4d438c459a53cf231ae16a6 Mon Sep 17 00:00:00 2001 From: Rias Date: Sun, 12 Jul 2026 14:28:33 +0200 Subject: [PATCH 2/4] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e195dbbeb..5af4e36a31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Updated core asset I/O to resolve Craft filesystem definitions and configured storage targets through Laravel filesystem disks. - Added `Illuminate\Contracts\Translation\HasLocalePreference` support to user elements, allowing Laravel notifications to use users’ Language preferences. ([#19228](https://github.com/craftcms/cms/pull/19228)) - Fixed a bug where site routes weren't being registered for each localized site value. - Fixed a bug where POST requests to the `loginPath` weren’t being handled properly. ([#19220](https://github.com/craftcms/cms/pull/19220)) From 5b42a922628a0f92dfb19240973976d8a92bca5a Mon Sep 17 00:00:00 2001 From: Rias Date: Sun, 12 Jul 2026 14:45:39 +0200 Subject: [PATCH 3/4] Fix temporary asset disk detection --- src/Asset/Data/Volume.php | 14 +++++++++++++- tests/Feature/Asset/Policies/AssetPolicyTest.php | 1 + yii2-adapter/legacy/debug/Module.php | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Asset/Data/Volume.php b/src/Asset/Data/Volume.php index a16f9fc2a4e..90bb3f7ccbf 100644 --- a/src/Asset/Data/Volume.php +++ b/src/Asset/Data/Volume.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Validation\VolumeRules; +use CraftCms\Cms\Cms; use CraftCms\Cms\Component\Component; use CraftCms\Cms\Component\Contracts\CpEditable; use CraftCms\Cms\Field\Enums\TranslationMethod; @@ -477,7 +478,18 @@ public function sourceFilesystemType(): string public function isTemporary(): bool { - return $this->_temporary; + if ($this->_temporary) { + return true; + } + + $tempUploadTarget = Env::parse(Cms::config()->tempAssetUploadFs); + if (! is_string($tempUploadTarget)) { + return false; + } + + $tempUploadDisk = Filesystems::resolveDiskName($tempUploadTarget); + + return $tempUploadDisk !== null && $this->resolveStorageTargetKey($this->_fsHandle) === $tempUploadDisk; } public function markAsTemporary(): void diff --git a/tests/Feature/Asset/Policies/AssetPolicyTest.php b/tests/Feature/Asset/Policies/AssetPolicyTest.php index 8fcab185791..eef3b688a5f 100644 --- a/tests/Feature/Asset/Policies/AssetPolicyTest.php +++ b/tests/Feature/Asset/Policies/AssetPolicyTest.php @@ -423,6 +423,7 @@ public function getFs(): Temp $mockVolume->name = $volume->name; $mockVolume->handle = $volume->handle; $mockVolume->mockFs = $tempFs; + $mockVolume->markAsTemporary(); $asset = new class extends Asset { diff --git a/yii2-adapter/legacy/debug/Module.php b/yii2-adapter/legacy/debug/Module.php index 42d3d929024..9472fbb28f3 100644 --- a/yii2-adapter/legacy/debug/Module.php +++ b/yii2-adapter/legacy/debug/Module.php @@ -128,7 +128,7 @@ private function resolveDiskFromHandle(?string $handle): FilesystemAdapter } if (Filesystems::getFilesystemByHandle($handle)) { - return Filesystems::disk($handle); // @phpstan-ignore return.type + return Filesystems::disk($handle); } throw new InvalidConfigException("Invalid debug filesystem handle: $handle"); From ec2dd04c870dd237d8324fddab427bd38cdae912 Mon Sep 17 00:00:00 2001 From: Rias Date: Sun, 12 Jul 2026 14:55:09 +0200 Subject: [PATCH 4/4] Fix Windows filesystem assertions --- .../Http/Controllers/Settings/FilesystemsControllerTest.php | 3 ++- tests/Feature/Image/ImageTransformerTest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php b/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php index 642e7ef0970..b34e5609cff 100644 --- a/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php +++ b/tests/Feature/Http/Controllers/Settings/FilesystemsControllerTest.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Filesystem\Filesystems\Local; use CraftCms\Cms\Http\Controllers\Settings\FilesystemsController; use CraftCms\Cms\Support\Facades\Filesystems; +use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Html; use CraftCms\Cms\User\Elements\User; use Illuminate\Support\Facades\Auth; @@ -167,7 +168,7 @@ expect($fs)->not()->toBeNull(); expect($fs->name)->toBe('New Test Filesystem'); expect($fs->getSettings())->toBe([ - 'path' => sys_get_temp_dir().'/test-uploads', + 'path' => File::normalizePath(sys_get_temp_dir().'/test-uploads', '/'), ]); }); diff --git a/tests/Feature/Image/ImageTransformerTest.php b/tests/Feature/Image/ImageTransformerTest.php index 44767380e0d..3fd34e94351 100644 --- a/tests/Feature/Image/ImageTransformerTest.php +++ b/tests/Feature/Image/ImageTransformerTest.php @@ -213,7 +213,7 @@ $this->transformer->deleteImageTransformFile($asset, $index); expect($asset->getVolume()->transformDisk()->exists($path))->toBeFalse(); - Event::assertDispatched(fn (DeletingTransformedImage $event): bool => $event->path === 'transforms/'.$path); + Event::assertDispatched(fn (DeletingTransformedImage $event): bool => $event->path === 'transforms'.DIRECTORY_SEPARATOR.$path); }); it('uses the provided asset when immediately generating transforms', function () {