diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index f64644164d3..74adb2b4d07 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -137,7 +137,10 @@ Craft's Mutex classes have been deprecated. [Laravel's atomic locking](https://l ## Assets +- Added `CraftCms\Cms\Support\Facades\Assets`. - Added `CraftCms\Cms\Support\Facades\AssetIndexer` facade. +- Added `CraftCms\Cms\Support\Facades\Folders`. +- Deprecated `craft\services\Assets`. `CraftCms\Cms\Asset\Assets` and `CraftCms\Cms\Asset\Folders` should be used instead. - Deprecated `\craft\records\Asset`. `\CraftCms\Cms\Asset\Models\Asset` should be used instead. - Deprecated `\craft\records\AssetIndexData`. `\CraftCms\Cms\Asset\Models\AssetIndexData` should be used instead. - Deprecated `\craft\records\AssetIndexingSession`. `\CraftCms\Cms\Asset\Models\AssetIndexingSession` should be used instead. @@ -157,6 +160,14 @@ Craft's Mutex classes have been deprecated. [Laravel's atomic locking](https://l - Deprecated `craft\errors\MissingVolumeFolderException`. `CraftCms\Cms\Asset\Exceptions\MissingVolumeFolderException` should be used instead. - Deprecated `craft\errors\VolumeException`. `CraftCms\Cms\Asset\Exceptions\VolumeException` should be used instead. +### Events + +- Deprecated `craft\events\ReplaceAssetEvent` in favor of the following new events: + - `craft\services\Assets::EVENT_BEFORE_REPLACE_ASSET` => `CraftCms\Cms\Asset\Events\BeforeReplaceAsset` + - `craft\services\Assets::EVENT_AFTER_REPLACE_ASSET` => `CraftCms\Cms\Asset\Events\AfterReplaceAsset` +- Deprecated `craft\events\DefineAssetThumbUrlEvent`. `CraftCms\Cms\Asset\Events\DefineThumbUrl` should be used instead. +- Deprecated `craft\events\AssetPreviewEvent`. `CraftCms\Cms\Asset\Events\RegisterPreviewHandler` should be used instead. + ## Auth - Refactored the authentication system to use Laravel's authentication system. diff --git a/routes/actions.php b/routes/actions.php index fa303b0ed0d..388b22681b8 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -7,6 +7,13 @@ use CraftCms\Cms\Http\Controllers\AddressesController; use CraftCms\Cms\Http\Controllers\AnnouncementsController; use CraftCms\Cms\Http\Controllers\ApiController; +use CraftCms\Cms\Http\Controllers\Assets\ActionController as AssetsActionController; +use CraftCms\Cms\Http\Controllers\Assets\FolderController as AssetsFolderController; +use CraftCms\Cms\Http\Controllers\Assets\IconController as AssetsIconController; +use CraftCms\Cms\Http\Controllers\Assets\ImageEditorController; +use CraftCms\Cms\Http\Controllers\Assets\PreviewController as AssetsPreviewController; +use CraftCms\Cms\Http\Controllers\Assets\TransformController; +use CraftCms\Cms\Http\Controllers\Assets\UploadController as AssetsUploadController; use CraftCms\Cms\Http\Controllers\Auth\LoginController; use CraftCms\Cms\Http\Controllers\Auth\PasskeyController; use CraftCms\Cms\Http\Controllers\Auth\SessionInfoController; @@ -108,6 +115,10 @@ Route::any('users/get-elevated-session-timeout', [SessionInfoController::class, 'confirmTimeout']); Route::middleware('throttle:1,1')->post('users/send-password-reset-email', [PasswordController::class, 'sendPasswordResetEmail']); Route::post('users/save-user', SaveUserController::class); + + // Asset Transforms (anonymous access) + Route::any('assets/generate-transform', [TransformController::class, 'generate']); + Route::get('assets/generate-fallback-transform', [TransformController::class, 'generateFallback']); }); } @@ -252,6 +263,26 @@ Route::post('asset-indexes/indexing-session-overview', [AssetIndexesController::class, 'indexingSessionOverview']); Route::post('asset-indexes/finish-indexing-session', [AssetIndexesController::class, 'finishIndexingSession']); + // Assets + Route::post('assets/upload', [AssetsUploadController::class, 'upload']); + Route::post('assets/replace-file', [AssetsUploadController::class, 'replaceFile']); + Route::post('assets/delete-asset', [AssetsActionController::class, 'deleteAsset']); + Route::post('assets/move-asset', [AssetsActionController::class, 'moveAsset']); + Route::post('assets/download-asset', [AssetsActionController::class, 'downloadAsset']); + Route::any('assets/show-in-folder', [AssetsActionController::class, 'showInFolder']); + Route::post('assets/move-info', [AssetsActionController::class, 'moveInfo']); + Route::post('assets/preview-thumb', [AssetsPreviewController::class, 'previewThumb']); + Route::post('assets/preview-file', [AssetsPreviewController::class, 'previewFile']); + Route::post('assets/create-folder', [AssetsFolderController::class, 'create']); + Route::post('assets/delete-folder', [AssetsFolderController::class, 'delete']); + Route::post('assets/rename-folder', [AssetsFolderController::class, 'rename']); + Route::post('assets/move-folder', [AssetsFolderController::class, 'move']); + Route::post('assets/image-editor', [ImageEditorController::class, 'show']); + Route::get('assets/edit-image', [ImageEditorController::class, 'editImage']); + Route::post('assets/save-image', [ImageEditorController::class, 'save']); + Route::post('assets/update-focal-position', [ImageEditorController::class, 'updateFocalPoint']); + Route::get('assets/icon/{extension?}', AssetsIconController::class); + // Preview Route::any('preview/create-token', [PreviewController::class, 'createToken']); diff --git a/routes/cp.php b/routes/cp.php index 9068f96042f..5c87d194243 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -4,6 +4,7 @@ use CraftCms\Cms\Auth\Enums\CpAuthPath; use CraftCms\Cms\Edition; +use CraftCms\Cms\Http\Controllers\Assets\IndexController as AssetsIndexController; use CraftCms\Cms\Http\Controllers\Auth\LoginController; use CraftCms\Cms\Http\Controllers\Auth\SetPasswordController; use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController; @@ -101,6 +102,12 @@ Route::get('users/{slug?}', [UsersController::class, 'index']); + /** + * Assets + */ + Route::get('assets/{defaultSource?}', AssetsIndexController::class) + ->where('defaultSource', '.*'); + /** * Routes that require admin, but do not require admin changes */ diff --git a/src/Asset/AssetIndexer.php b/src/Asset/AssetIndexer.php index 370b8d319a3..b112403bb64 100644 --- a/src/Asset/AssetIndexer.php +++ b/src/Asset/AssetIndexer.php @@ -51,6 +51,7 @@ final class AssetIndexer public function __construct( private readonly Volumes $volumes, + private readonly Folders $folders, ) {} public function getIndexListOnVolume(Volume $volume, string $directory = ''): Generator @@ -591,8 +592,7 @@ public function indexFileByEntry( $path = "$dirname/"; } - $assets = Craft::$app->getAssets(); - $folder = $assets->findFolder([ + $folder = $this->folders->findFolder([ 'volumeId' => $indexEntry->volumeId, 'path' => $path, 'parentId' => $parentId, @@ -601,7 +601,7 @@ public function indexFileByEntry( if (! $folder) { /** @var Volume $volume */ $volume = $this->volumes->getVolumeById($indexEntry->volumeId); - $folder = $assets->ensureFolderByFullPathAndVolume($path, $volume); + $folder = $this->folders->ensureFolderByFullPathAndVolume($path, $volume); } else { $volume = $folder->getVolume(); } @@ -707,7 +707,7 @@ public function indexFolderByEntry(AssetIndexEntry $indexEntry, bool $createIfMi } } - $folder = Craft::$app->getAssets()->findFolder([ + $folder = $this->folders->findFolder([ 'path' => "$indexEntry->uri/", 'volumeId' => $indexEntry->volumeId, ]); @@ -719,7 +719,7 @@ public function indexFolderByEntry(AssetIndexEntry $indexEntry, bool $createIfMi throw new MissingVolumeFolderException($indexEntry, $volume, $indexEntry->uri); } - return Craft::$app->getAssets()->ensureFolderByFullPathAndVolume($indexEntry->uri ?? '', $volume); + return $this->folders->ensureFolderByFullPathAndVolume($indexEntry->uri ?? '', $volume); } private function storeIndexingSession(IndexingSession $session): void diff --git a/src/Asset/Assets.php b/src/Asset/Assets.php new file mode 100644 index 00000000000..d19a9df7381 --- /dev/null +++ b/src/Asset/Assets.php @@ -0,0 +1,402 @@ +getElements()->getElementById($assetId, Asset::class, $siteId); + } + + public function getTotalAssets(mixed $criteria = null): int + { + if ($criteria instanceof AssetQuery) { + return $criteria->count(); + } + + $query = Asset::find(); + + if ($criteria) { + Craft::configure($query, $criteria); + } + + return $query->count(); + } + + public function replaceAssetFile(Asset $asset, string $pathOnServer, string $filename, ?string $mimeType = null): void + { + event($event = new BeforeReplaceAsset( + asset: $asset, + replaceWith: $pathOnServer, + filename: $filename, + )); + + $filename = $event->filename; + + $asset->tempFilePath = $pathOnServer; + $asset->newFilename = $filename; + $asset->setMimeType(FileHelper::getMimeType($pathOnServer, checkExtension: false) ?? $mimeType); + $asset->uploaderId = Auth::user()?->id; + $asset->avoidFilenameConflicts = true; + $asset->setScenario(Asset::SCENARIO_REPLACE); + Craft::$app->getElements()->saveElement($asset); + + event(new AfterReplaceAsset( + asset: $asset, + filename: $filename, + )); + } + + public function moveAsset(Asset $asset, VolumeFolder $folder, string $filename = ''): bool + { + $folderChanging = $asset->folderId != $folder->id; + $filenameChanging = $filename !== '' && $filename !== $asset->getFilename(); + + if (! $folderChanging && ! $filenameChanging) { + return true; + } + + if ($folderChanging) { + $asset->newFolderId = $folder->id; + } + + if ($filenameChanging) { + $asset->newFilename = $filename; + $asset->setScenario(Asset::SCENARIO_FILEOPS); + } else { + $asset->setScenario(Asset::SCENARIO_MOVE); + } + + return Craft::$app->getElements()->saveElement($asset); + } + + public function getThumbUrl(Asset $asset, int $width, ?int $height = null, bool $iconFallback = true): ?string + { + $height ??= $width; + + event($event = new DefineThumbUrl( + asset: $asset, + width: $width, + height: $height, + )); + + if ($event->url !== null) { + return $event->url; + } + + $extension = $asset->getExtension(); + + if (! Image::canManipulateAsImage($extension)) { + return $iconFallback ? AssetsHelper::iconUrl($extension) : null; + } + + $transform = Craft::createObject([ + 'class' => ImageTransform::class, + 'width' => $width, + 'height' => $height, + 'mode' => 'crop', + ]); + + $url = $asset->getUrl($transform); + + if (! $url) { + $transform->setTransformer(FallbackTransformer::class); + $url = $asset->getUrl($transform); + } + + if ($url === null) { + return $iconFallback ? AssetsHelper::iconUrl($extension) : null; + } + + return AssetsHelper::revUrl($url, $asset, fsOnly: true); + } + + /** + * @throws NotSupportedException if the asset's volume doesn't have a filesystem with public URLs + */ + public function getImagePreviewUrl(Asset $asset, int $maxWidth, int $maxHeight): string + { + $isWebSafe = Image::isWebSafe($asset->getExtension()); + $originalWidth = (int) $asset->getWidth(); + $originalHeight = (int) $asset->getHeight(); + [$width, $height] = AssetsHelper::scaledDimensions((int) $asset->getWidth(), (int) $asset->getHeight(), $maxWidth, $maxHeight); + + if ( + ! $isWebSafe || + ! $asset->getVolume()->getFs()->hasUrls || + $originalWidth > $width || + $originalHeight > $height + ) { + $transform = Craft::createObject([ + 'class' => ImageTransform::class, + 'width' => $width, + 'height' => $height, + 'mode' => 'crop', + ]); + } else { + $transform = null; + } + + if (! $url = $asset->getUrl($transform, true)) { + throw new NotSupportedException('A preview URL couldn’t be generated for the asset.'); + } + + return AssetsHelper::revUrl($url, $asset, fsOnly: true); + } + + /** + * @throws AssetOperationException + * @throws InvalidConfigException + */ + public function getNameReplacementInFolder(string $originalFilename, int $folderId): string + { + $folder = $this->folders->getFolderById($folderId); + + if (! $folder) { + throw new InvalidArgumentException("Invalid folder ID: $folderId"); + } + + $volume = $folder->getVolume(); + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + + $buildFilename = function (string $name, string $suffix = '') use ($extension) { + $maxLength = 255 - strlen($suffix); + if ($extension !== '') { + $maxLength -= strlen($extension) + 1; + } + if (strlen($name) > $maxLength) { + $name = substr($name, 0, $maxLength); + } + + return $name.$suffix; + }; + + $baseFileName = $buildFilename(pathinfo($originalFilename, PATHINFO_FILENAME)); + + $dbFileList = DB::table(new Alias(Table::ASSETS, 'assets')) + ->join(new Alias(Table::ELEMENTS, 'elements'), 'elements.id', 'assets.id') + ->where('assets.folderId', $folderId) + ->whereNull('elements.dateDeleted') + ->whereLike('assets.filename', $baseFileName.'%.'.$extension) + ->pluck('assets.filename'); + + $potentialConflicts = []; + + foreach ($dbFileList as $filename) { + $potentialConflicts[mb_strtolower((string) $filename)] = true; + } + + $canUse = static fn ($filenameToTest) => ! isset($potentialConflicts[mb_strtolower((string) $filenameToTest)]) + && ! $volume->sourceDisk()->exists($folder->path.$filenameToTest); + + if ($canUse($originalFilename)) { + return $originalFilename; + } + + if (preg_match('/.*_\d{4}-\d{2}-\d{2}-\d{6}$/', $baseFileName, $matches)) { + $base = $baseFileName; + } else { + $timestamp = DateTimeHelper::currentUTCDateTime()->format('Y-m-d-His'); + $base = $buildFilename($baseFileName, '_'.$timestamp); + } + + $base = $buildFilename($base, sprintf('_%s', Str::random(4))); + + $increment = 0; + + while (true) { + $suffix = $increment ? "_$increment" : ''; + $newFilename = $buildFilename($base, $suffix).($extension !== '' ? ".$extension" : ''); + + if ($canUse($newFilename)) { + break; + } + + if ($increment === 50) { + throw new AssetOperationException(t('Could not find a suitable replacement filename for "{filename}".', [ + 'filename' => $originalFilename, + ])); + } + + $increment++; + } + + return $newFilename; + } + + public function getAssetPreviewHandler(Asset $asset): ?AssetPreviewHandlerInterface + { + event($event = new RegisterPreviewHandler(asset: $asset)); + + if ($event->previewHandler instanceof AssetPreviewHandlerInterface) { + return $event->previewHandler; + } + + return match ($asset->kind) { + Asset::KIND_IMAGE => new ImagePreview($asset), + Asset::KIND_PDF => new Pdf($asset), + Asset::KIND_VIDEO => new Video($asset), + Asset::KIND_HTML, Asset::KIND_JAVASCRIPT, Asset::KIND_JSON, Asset::KIND_PHP, Asset::KIND_TEXT, Asset::KIND_XML => new Text($asset), + default => null, + }; + } + + /** + * @throws InvalidConfigException + */ + public function getTempAssetUploadFs(): FsInterface + { + $handle = Env::parse(Cms::config()->tempAssetUploadFs); + + if (! $handle) { + return new Temp; + } + + return Filesystems::resolve($handle) + ?? throw new InvalidConfigException("The tempAssetUploadFs config setting is set to an invalid filesystem value: $handle"); + } + + /** + * @throws InvalidConfigException + */ + public function getTempAssetUploadDisk(): FilesystemAdapter + { + $handle = Env::parse(Cms::config()->tempAssetUploadFs); + + if (! $handle) { + return Storage::build([ // @phpstan-ignore return.type + 'driver' => 'local', + 'root' => Craft::$app->getPath()->getTempAssetUploadsPath(), + ]); + } + + return Storage::disk( + Filesystems::resolveDiskName($handle) + ?? throw new InvalidConfigException("The tempAssetUploadFs config setting is set to an invalid filesystem value: $handle") + ); + } + + public function createTempAssetQuery(): AssetQuery + { + return new AssetQuery()->volumeId(':empty:'); + } + + /** + * @throws VolumeException + */ + public function getUserTemporaryUploadFolder(?User $user = null): VolumeFolder + { + $user ??= Auth::user(); + $cacheKey = $user->id ?? '__GUEST__'; + + if (isset($this->userTempFolders[$cacheKey])) { + return $this->userTempFolders[$cacheKey]; + } + + $folders = $this->folders; + + if ($user) { + $folderName = "user_{$user->id}"; + } elseif (app()->runningInConsole()) { + $folderName = 'temp_'.sha1((string) time()); + } else { + $folderName = 'user_'.sha1(session()->id()); + } + + $volumeTopFolder = $folders->findFolder([ + 'volumeId' => ':empty:', + 'parentId' => ':empty:', + ]); + + if (! $volumeTopFolder) { + $volumeTopFolder = new VolumeFolder; + $volumeTopFolder->name = t('Temporary Uploads'); + $folders->storeFolderModel($volumeTopFolder); + } + + $folder = $folders->findFolder([ + 'name' => $folderName, + 'parentId' => $volumeTopFolder->id, + ]); + + if (! $folder) { + $folder = new VolumeFolder; + $folder->parentId = $volumeTopFolder->id; + $folder->name = $folderName; + $folder->path = $folderName.'/'; + $folders->storeFolderModel($folder); + } + + $disk = $this->getTempAssetUploadDisk(); + + try { + if (! $disk->directoryExists($folderName) && ! $disk->makeDirectory($folderName)) { + throw new VolumeException('Unable to create directory for temporary uploads.'); + } + } catch (Throwable) { + throw new VolumeException('Unable to create directory for temporary uploads.'); + } + + $folder->name = t('Temporary Uploads'); + + return $this->userTempFolders[$cacheKey] = $folder; + } + + public function reset(): void + { + $this->userTempFolders = []; + } +} diff --git a/src/Asset/Commands/Concerns/IndexesAssets.php b/src/Asset/Commands/Concerns/IndexesAssets.php index aa2d2e354b5..64e32a397db 100644 --- a/src/Asset/Commands/Concerns/IndexesAssets.php +++ b/src/Asset/Commands/Concerns/IndexesAssets.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Filesystem\Data\FsListing; use CraftCms\Cms\Support\Facades\AssetIndexer; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Str; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -123,8 +124,8 @@ function () use ($craft, $assetIds) { $this->components->task( 'Deleting the'.($totalMissingFolders > 1 ? ' '.$totalMissingFolders : '').' missing and empty '.Str::plural('folder', $totalMissingFolders), - function () use ($missingFolders, $craft) { - $craft->getAssets()->deleteFoldersByIds(array_keys($missingFolders)); + function () use ($missingFolders) { + Folders::deleteFoldersByIds(array_keys($missingFolders)); } ); } diff --git a/src/Asset/Concerns/EnforcesVolumePermissions.php b/src/Asset/Concerns/EnforcesVolumePermissions.php new file mode 100644 index 00000000000..d190698c30a --- /dev/null +++ b/src/Asset/Concerns/EnforcesVolumePermissions.php @@ -0,0 +1,78 @@ +getVolumeId()) { + $userTemporaryFolder = Assets::getUserTemporaryUploadFolder(); + + // Skip permission check only if it's the user's temporary folder + if ($userTemporaryFolder->id == $asset->folderId) { + return; + } + } + + $volume = $asset->getVolume(); + $this->requireVolumePermission($permissionName, $volume->uid); + } + + /** + * Requires a peer permission for a given asset, unless it was uploaded by the current user. + */ + protected function requirePeerVolumePermissionByAsset(string $permissionName, Asset $asset): void + { + if (! $asset->getVolumeId()) { + return; + } + + if ($asset->uploaderId != Auth::id()) { + $this->requireVolumePermissionByAsset($permissionName, $asset); + } + } + + /** + * Requires a volume permission for a given folder. + * + * Skips permission check for the user's temporary upload folder. + */ + protected function requireVolumePermissionByFolder(string $permissionName, VolumeFolder $folder): void + { + if (! $folder->volumeId) { + $userTemporaryFolder = Assets::getUserTemporaryUploadFolder(); + + // Skip permission check only if it's the user's temporary folder + if ($userTemporaryFolder->id == $folder->id) { + return; + } + } + + $volume = $folder->getVolume(); + $this->requireVolumePermission($permissionName, $volume->uid); + } + + /** + * Requires a volume permission by its UID. + */ + protected function requireVolumePermission(string $permissionName, string $volumeUid): void + { + $this->requirePermission($permissionName.':'.$volumeUid); + } +} diff --git a/src/Asset/Contracts/AssetPreviewHandlerInterface.php b/src/Asset/Contracts/AssetPreviewHandlerInterface.php new file mode 100644 index 00000000000..80eb916a4d4 --- /dev/null +++ b/src/Asset/Contracts/AssetPreviewHandlerInterface.php @@ -0,0 +1,20 @@ +_hasChildren)) { - $this->_hasChildren = Craft::$app->getAssets()->foldersExist(['parentId' => $this->id]); + $this->_hasChildren = Folders::foldersExist(['parentId' => $this->id]); } return $this->_hasChildren; @@ -211,7 +211,7 @@ public function setChildren(array $children): void public function getChildren(): array { if ($this->_children === null) { - $this->_children = Craft::$app->getAssets()->findFolders(['parentId' => $this->id]); + $this->_children = Folders::findFolders(['parentId' => $this->id])->all(); } return $this->_children; @@ -223,7 +223,7 @@ public function getParent(): ?self return null; } - return Craft::$app->getAssets()->getFolderById((int) $this->parentId); + return Folders::getFolderById((int) $this->parentId); } public function addChild(self $folder): void diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index cdc3b186f69..4bc32cdd4ce 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -8,7 +8,6 @@ use craft\base\ElementInterface; use craft\controllers\ElementIndexesController; use craft\controllers\ElementSelectorModalsController; -use craft\db\Query; use craft\db\QueryAbortedException; use craft\elements\actions\CopyReferenceTag; use craft\elements\actions\CopyUrl; @@ -59,13 +58,14 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Field\Field; use CraftCms\Cms\FieldLayout\FieldLayout; -use CraftCms\Cms\Filesystem\Contracts\FsInterface; use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; use CraftCms\Cms\Filesystem\Filesystems\Filesystem; use CraftCms\Cms\Search\SearchQuery; use CraftCms\Cms\Search\SearchQueryTerm; use CraftCms\Cms\Search\SearchQueryTermGroup; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Assets as AssetsService; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\InputNamespace; @@ -74,6 +74,7 @@ use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; +use CraftCms\Cms\Support\Query; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; use CraftCms\Cms\User\Elements\User; @@ -81,6 +82,7 @@ use DateInterval; use DateTime; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; use Illuminate\Filesystem\LocalFilesystemAdapter; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; @@ -119,7 +121,6 @@ * @property-read bool $hasCheckeredThumb * @property-read bool $supportsImageEditor * @property-read array $previewTargets - * @property-read FsInterface $fs * @property-read string $titleTranslationKey * @property-read null|string $titleTranslationDescription * @property-read string $dataUrl @@ -355,11 +356,10 @@ protected static function defineSources(string $context): array $volumeIds = Volumes::getAllVolumeIds(); } - $assetsService = Craft::$app->getAssets(); $user = Auth::user(); foreach ($volumeIds as $volumeId) { - $folder = $assetsService->getRootFolderByVolumeId($volumeId); + $folder = Folders::getRootFolderByVolumeId($volumeId); $sources[] = self::_assembleSourceInfoForFolder($folder, $user); } @@ -368,8 +368,8 @@ protected static function defineSources(string $context): array $context !== ElementSources::CONTEXT_SETTINGS && ! Craft::$app->getRequest()->getIsConsoleRequest() ) { - $temporaryUploadFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); - $temporaryUploadFs = Craft::$app->getAssets()->getTempAssetUploadFs(); + $temporaryUploadFolder = AssetsService::getUserTemporaryUploadFolder(); + $temporaryUploadFs = AssetsService::getTempAssetUploadFs(); $sources[] = [ 'key' => 'temp', 'label' => t('Temporary Uploads'), @@ -393,7 +393,7 @@ protected static function defineSources(string $context): array public static function findSource(string $sourceKey, ?string $context): ?array { if (preg_match('/^volume:[\w\-]+(?:\/.+)?\/folder:([\w\-]+)$/', $sourceKey, $match)) { - $folder = Craft::$app->getAssets()->getFolderByUid($match[1]); + $folder = Folders::getFolderByUid($match[1]); if ($folder) { $source = self::_assembleSourceInfoForFolder($folder, Auth::user()); $source['keyPath'] = $sourceKey; @@ -411,7 +411,7 @@ public static function sourcePath(string $sourceKey, string $stepKey, ?string $c return null; } - $folder = Craft::$app->getAssets()->getFolderByUid($match[1]); + $folder = Folders::getFolderByUid($match[1]); if (! $folder) { return null; @@ -449,7 +449,7 @@ protected static function defineActions(string $source): array if (preg_match('/^volume:([a-z0-9\-]+)/', $source, $matches)) { $volume = Volumes::getVolumeByUid($matches[1]); } elseif (preg_match('/^folder:([a-z0-9\-]+)/', $source, $matches)) { - $folder = Craft::$app->getAssets()->getFolderByUid($matches[1]); + $folder = Folders::getFolderByUid($matches[1]); $volume = $folder?->getVolume(); } @@ -697,24 +697,23 @@ protected static function indexElements(ElementQueryInterface $elementQuery, ?st // Include folders in the results? /** @var AssetQuery $elementQuery */ if (self::_includeFoldersInIndexElements($elementQuery, $sourceKey, $queryFolder)) { - $assetsService = Craft::$app->getAssets(); $folderQuery = self::_createFolderQueryForIndex($elementQuery, $queryFolder); $totalFolders = $folderQuery->count(); if ((int) $totalFolders > (int) $elementQuery->offset) { $source = ElementHelper::findSource(self::class, $sourceKey); if (isset($source['criteria']['folderId'])) { - $baseFolder = $assetsService->getFolderById($source['criteria']['folderId']); + $baseFolder = Folders::getFolderById($source['criteria']['folderId']); } else { - $baseFolder = $assetsService->getRootFolderByVolumeId($queryFolder->getVolume()->id); + $baseFolder = Folders::getRootFolderByVolumeId($queryFolder->getVolume()->id); } $baseSourcePathStep = $baseFolder->getSourcePathInfo(); $folderQuery - ->offset($elementQuery->offset) - ->limit($elementQuery->limit); + ->when($elementQuery->getOffset(), fn ($query) => $query->offset($elementQuery->getOffset())) + ->when($elementQuery->getLimit(), fn ($query) => $query->limit($elementQuery->getLimit())); - $folders = array_map(fn (array $result) => new VolumeFolder($result), $folderQuery->all()); + $folders = $folderQuery->get()->map(fn (object $result) => new VolumeFolder((array) $result))->all(); $foldersByPath = Arr::keyBy($folders, fn (VolumeFolder $folder) => rtrim((string) $folder->path, '/')); @@ -727,12 +726,12 @@ protected static function indexElements(ElementQueryInterface $elementQuery, ?st if (isset($foldersByPath[$path])) { $stepFolder = $foldersByPath[$path]; } else { - $stepFolder = $assetsService->findFolder([ + $stepFolder = Folders::findFolder([ 'volumeId' => $queryFolder->volumeId, 'path' => "$path/", ]); if (! $stepFolder) { - $stepFolder = $assetsService->ensureFolderByFullPathAndVolume($path, $queryFolder->getVolume()); + $stepFolder = Folders::ensureFolderByFullPathAndVolume($path, $queryFolder->getVolume()); } $foldersByPath[$path] = $stepFolder; } @@ -744,7 +743,8 @@ protected static function indexElements(ElementQueryInterface $elementQuery, ?st } $path = rtrim((string) $folder->path, '/'); - $path = Str::between($path, $queryFolder->path ?? '', $folder->name); + $path = Str::chopStart($path, $queryFolder->path ?? ''); + $path = Str::beforeLast($path, $folder->name); $assets[] = new self([ 'isFolder' => true, @@ -811,8 +811,7 @@ private static function _includeFoldersInIndexElements(AssetQuery $assetQuery, ? } if ($queryFolder === null) { - $assetsService = Craft::$app->getAssets(); - $queryFolder = $assetsService->getFolderById($assetQuery->folderId); + $queryFolder = Folders::getFolderById($assetQuery->folderId); if (! $queryFolder) { return false; } @@ -846,35 +845,34 @@ private static function _validateSearchTermForIndex(SearchQueryTerm|SearchQueryT /** * @throws QueryAbortedException */ - private static function _createFolderQueryForIndex(AssetQuery $assetQuery, ?VolumeFolder $queryFolder = null): Query + private static function _createFolderQueryForIndex(AssetQuery $assetQuery, ?VolumeFolder $queryFolder = null): Builder { if ( is_array($assetQuery->orders) && is_string($firstOrderByCol = $assetQuery->orders[0]['column']) && in_array($firstOrderByCol, ['title', 'filename']) ) { - $sortDir = $assetQuery->orders[0]['direction'] === 'asc' ? SORT_ASC : SORT_DESC; + $sortDir = $assetQuery->orders[0]['direction'] === 'asc' ? 'asc' : 'desc'; } else { - $sortDir = SORT_ASC; + $sortDir = 'asc'; } - $assetsService = Craft::$app->getAssets(); - $query = $assetsService->createFolderQuery() - ->orderBy(['name' => $sortDir]); + $query = Folders::createFolderQuery() + ->orderBy('name', $sortDir); if ($assetQuery->includeSubfolders) { if ($queryFolder === null) { - $queryFolder = $assetsService->getFolderById($assetQuery->folderId); + $queryFolder = Folders::getFolderById($assetQuery->folderId); if (! $queryFolder) { throw new QueryAbortedException; } } $query - ->where(['volumeId' => $queryFolder->volumeId]) - ->andWhere(['not', ['id' => $queryFolder->id]]) - ->andWhere(['like', 'path', "$queryFolder->path%", false]); + ->where('volumeId', $queryFolder->volumeId) + ->where('id', '!=', $queryFolder->id) + ->where('path', 'like', "$queryFolder->path%"); } else { - $query->where(['parentId' => $assetQuery->folderId]); + $query->where('parentId', $assetQuery->folderId); } if ($assetQuery->search) { @@ -883,40 +881,53 @@ private static function _createFolderQueryForIndex(AssetQuery $assetQuery, ?Volu /** @var SearchQuery $searchQuery */ $searchQuery = $assetQuery->search; $token = Arr::first($searchQuery->getTokens()); - $query->andWhere(self::_buildFolderQuerySearchCondition($token)); + self::_applyFolderQuerySearchCondition($query, $token); } return $query; } - private static function _buildFolderQuerySearchCondition(SearchQueryTerm|SearchQueryTermGroup $token): array + private static function _applyFolderQuerySearchCondition(Builder $query, SearchQueryTerm|SearchQueryTermGroup $token): void { if ($token instanceof SearchQueryTermGroup) { - $condition = ['or']; - foreach ($token->terms as $term) { - $condition[] = self::_buildFolderQuerySearchCondition($term); - } + $query->where(function (Builder $q) use ($token) { + foreach ($token->terms as $i => $term) { + if ($i === 0) { + self::_applyFolderQuerySearchCondition($q, $term); + } else { + $q->orWhere(function (Builder $sub) use ($term) { + self::_applyFolderQuerySearchCondition($sub, $term); + }); + } + } + }); - return $condition; + return; } - $isPgsql = Craft::$app->getDb()->getIsPgsql(); + $isPgsql = DbFacade::getDriverName() === 'pgsql'; /** @var SearchQueryTerm $token */ if ($token->subLeft || $token->subRight) { - return [$isPgsql ? 'ilike' : 'like', 'name', sprintf('%s%s%s', + $pattern = sprintf( + '%s%s%s', $token->subLeft ? '%' : '', $token->term, $token->subRight ? '%' : '', - ), false]; + ); + $query->where('name', $isPgsql ? 'ilike' : 'like', $pattern); + + return; } // Only Postgres supports case-sensitive queries if ($isPgsql) { - return ['=', 'lower([[name]])', mb_strtolower((string) $token->term)]; + $query->whereRaw('lower("name") = ?', [mb_strtolower((string) $token->term)]); + + return; } - return ['name' => $token->term]; + $query->where('name', $token->term); } /** @@ -1376,7 +1387,7 @@ protected function safeActionMenuItems(): array $viewItems = []; // Preview - if (Craft::$app->getAssets()->getAssetPreviewHandler($this) !== null) { + if (AssetsService::getAssetPreviewHandler($this) !== null) { $previewId = sprintf('action-preview-%s', mt_rand()); $viewItems[] = [ 'type' => MenuItemType::Button, @@ -1578,7 +1589,7 @@ protected function safeActionMenuItems(): array InputNamespace::namespaceId($replaceId), InputNamespace::get(), $this->id, - $this->fs::class, + $this->getVolume()->getFs()::class, t('Dimensions'), ]); } @@ -1843,7 +1854,7 @@ public function getFolder(): VolumeFolder throw new InvalidConfigException('Asset is missing its folder ID'); } - if (($folder = Craft::$app->getAssets()->getFolderById($this->folderId)) === null) { + if (($folder = Folders::getFolderById($this->folderId)) === null) { throw new InvalidConfigException('Invalid folder ID: '.$this->folderId); } @@ -2038,7 +2049,7 @@ protected function thumbUrl(int $size): ?string $width = $height = $size; } - return Craft::$app->getAssets()->getThumbUrl($this, $width, $height, false); + return AssetsService::getThumbUrl($this, $width, $height, false); } protected function thumbSvg(): string @@ -2085,10 +2096,8 @@ public function getPreviewThumbImg(int $desiredWidth, int $desiredHeight): strin [$width, $height], [$width * 2, $height * 2], ]; - $assetsService = Craft::$app->getAssets(); - foreach ($thumbSizes as [$width, $height]) { - $url = $assetsService->getThumbUrl($this, $width, $height); + $url = AssetsService::getThumbUrl($this, $width, $height); $srcsets[] = sprintf('%s %sw', $url, $width); } @@ -2560,7 +2569,7 @@ public function getPreviewHtml(): string $userSession = Craft::$app->getUser(); $volume = $this->getVolume(); - $previewable = Craft::$app->getAssets()->getAssetPreviewHandler($this) !== null; + $previewable = AssetsService::getAssetPreviewHandler($this) !== null; $editable = ( $this->getSupportsImageEditor() && Gate::check("editImages:$volume->uid") && @@ -2906,7 +2915,7 @@ public function beforeSave(bool $isNew): bool } // Set the field layout - $volume = Craft::$app->getAssets()->getFolderById($folderId)->getVolume(); + $volume = Folders::getFolderById($folderId)->getVolume(); if (! Assets::isTempUploadFs($volume->getFs())) { $this->fieldLayoutId = $volume->fieldLayoutId; @@ -2984,7 +2993,7 @@ public function afterSave(bool $isNew): void $model->size = (int) $this->size ?: null; $model->width = (int) $this->_width ?: $fallbackWidth; $model->height = (int) $this->_height ?: $fallbackHeight; - $model->dateModified = \CraftCms\Cms\Support\Query::prepareDateForDb($this->dateModified); + $model->dateModified = Query::prepareDateForDb($this->dateModified); if (isset($this->_mimeType)) { $model->mimeType = $this->_mimeType; @@ -3080,7 +3089,7 @@ public function getHtmlAttributes(string $context): array 'folder-id' => $this->folderId, 'folder-name' => $this->title, 'source-path' => Json::encode($this->sourcePath), - 'has-children' => Craft::$app->getAssets()->foldersExist(['parentId' => $this->folderId]), + 'has-children' => Folders::foldersExist(['parentId' => $this->folderId]), ], ]; @@ -3204,8 +3213,6 @@ private function _dimensions(mixed $transform = null): array */ private function _relocateFile(): void { - $assetsService = Craft::$app->getAssets(); - // Get the (new?) folder ID & filename if (isset($this->newLocation)) { [$folderId, $filename] = Assets::parseFileLocation($this->newLocation); @@ -3218,10 +3225,10 @@ private function _relocateFile(): void $tempPath = null; - $oldFolder = $this->folderId ? $assetsService->getFolderById($this->folderId) : null; + $oldFolder = $this->folderId ? Folders::getFolderById($this->folderId) : null; $oldVolume = $oldFolder?->getVolume(); - $newFolder = $hasNewFolder ? $assetsService->getFolderById($folderId) : $oldFolder; + $newFolder = $hasNewFolder ? Folders::getFolderById($folderId) : $oldFolder; $newVolume = $hasNewFolder ? $newFolder->getVolume() : $oldVolume; $oldPath = $this->folderId ? $this->getPath() : null; diff --git a/src/Asset/Events/AfterReplaceAsset.php b/src/Asset/Events/AfterReplaceAsset.php new file mode 100644 index 00000000000..63634fca04e --- /dev/null +++ b/src/Asset/Events/AfterReplaceAsset.php @@ -0,0 +1,18 @@ + */ + private array $foldersById = []; + + /** @var array */ + private array $foldersByUid = []; + + /** @var array */ + private array $rootFolders = []; + + public function getFolderById(int $folderId): ?VolumeFolder + { + if (! array_key_exists($folderId, $this->foldersById)) { + $result = $this->createFolderQuery() + ->where('id', $folderId) + ->first(); + + $this->foldersById[$folderId] = $result ? new VolumeFolder((array) $result) : null; + } + + return $this->foldersById[$folderId]; + } + + public function getFolderByUid(string $folderUid): ?VolumeFolder + { + if (! array_key_exists($folderUid, $this->foldersByUid)) { + $result = $this->createFolderQuery() + ->where('uid', $folderUid) + ->first(); + + $this->foldersByUid[$folderUid] = $result ? new VolumeFolder((array) $result) : null; + } + + return $this->foldersByUid[$folderUid]; + } + + /** + * @return Collection + */ + public function findFolders(mixed $criteria = []): Collection + { + if (! $criteria instanceof FolderCriteria) { + $criteria = new FolderCriteria($criteria); + } + + $query = $this->createFolderQuery(); + + $this->applyFolderConditions($query, $criteria); + + if ($criteria->order) { + if (is_string($criteria->order)) { + $parts = explode(' ', $criteria->order, 2); + $query->orderBy($parts[0], $parts[1] ?? 'asc'); + } else { + foreach ($criteria->order as $column => $direction) { + if (is_int($column)) { + $query->orderByRaw((string) $direction); + } else { + $query->orderBy($column, $direction === SORT_DESC ? 'desc' : 'asc'); + } + } + } + } + + if ($criteria->offset) { + $query->offset($criteria->offset); + } + + if ($criteria->limit) { + $query->limit($criteria->limit); + } + + return $query->get() + ->map(fn ($result) => new VolumeFolder((array) $result)) + ->each(fn (VolumeFolder $folder) => $this->foldersById[$folder->id] = $folder) + ->keyBy('id'); + } + + public function findFolder(mixed $criteria = []): ?VolumeFolder + { + if (! $criteria instanceof FolderCriteria) { + $criteria = new FolderCriteria($criteria); + } + + $criteria->limit = 1; + + return $this->findFolders($criteria)->first(); + } + + /** + * @return array + */ + public function getAllDescendantFolders( + VolumeFolder $parentFolder, + string $orderBy = 'path', + bool $withParent = true, + bool $asTree = false, + ): array { + $query = $this->createFolderQuery() + ->where('volumeId', $parentFolder->volumeId) + ->whereNotNull('parentId'); + + if ($parentFolder->path !== null) { + $escapedPath = str_replace('_', '\\_', $parentFolder->path); + $query->where('path', 'like', $escapedPath.'%'); + } + + if ($orderBy) { + $query->orderBy($orderBy); + } + + if (! $withParent) { + $query->where('id', '!=', $parentFolder->id); + } + + $results = $query->get(); + $descendantFolders = []; + + foreach ($results as $result) { + $folder = new VolumeFolder((array) $result); + $this->foldersById[$folder->id] = $folder; + $descendantFolders[$folder->id] = $folder; + } + + if ($asTree) { + return $this->buildFolderTree($descendantFolders); + } + + return $descendantFolders; + } + + public function getRootFolderByVolumeId(int $volumeId): ?VolumeFolder + { + if (! array_key_exists($volumeId, $this->rootFolders)) { + $volume = Volumes::getVolumeById($volumeId); + + if (! $volume) { + return $this->rootFolders[$volumeId] = null; + } + + $folder = $this->findFolder([ + 'volumeId' => $volumeId, + 'parentId' => ':empty:', + ]); + + if (! $folder) { + $folder = new VolumeFolder; + $folder->volumeId = $volume->id; + $folder->parentId = null; + $folder->name = $volume->name; + $folder->path = ''; + $this->storeFolderModel($folder); + } + + $this->rootFolders[$volumeId] = $folder; + } + + return $this->rootFolders[$volumeId]; + } + + public function getTotalFolders(mixed $criteria): int + { + if (! $criteria instanceof FolderCriteria) { + $criteria = new FolderCriteria($criteria); + } + + $query = DB::table(Table::VOLUMEFOLDERS); + + $this->applyFolderConditions($query, $criteria); + + return $query->count('id'); + } + + public function foldersExist(mixed $criteria = null): bool + { + if (! ($criteria instanceof FolderCriteria)) { + $criteria = new FolderCriteria($criteria); + } + + $query = DB::table(Table::VOLUMEFOLDERS); + + $this->applyFolderConditions($query, $criteria); + + return $query->exists(); + } + + /** + * @throws FsObjectExistsException if a folder already exists with such a name + * @throws FilesystemException if unable to create the directory on volume + * @throws AssetException if invalid folder provided + */ + public function createFolder(VolumeFolder $folder): void + { + $parent = $folder->getParent(); + + if (! $parent) { + throw new AssetException("Folder {$folder->id} doesn’t have a parent."); + } + + $existingFolder = $this->findFolder([ + 'parentId' => $folder->parentId, + 'name' => $folder->name, + ]); + + if ($existingFolder && (! $folder->id || $folder->id !== $existingFolder->id)) { + throw new FsObjectExistsException(t( + 'A folder with the name "{folderName}" already exists in the volume.', + ['folderName' => $folder->name] + )); + } + + $path = rtrim((string) $folder->path, '/'); + + if (! $parent->getVolume()->sourceDisk()->makeDirectory($path)) { + throw new FilesystemException("Unable to create directory at path: $path"); + } + + $this->storeFolderModel($folder); + } + + /** + * @return string The new folder name after cleaning it. + * + * @throws AssetOperationException + * @throws FsObjectExistsException + * @throws FsObjectNotFoundException + */ + public function renameFolderById(int $folderId, string $newName): string + { + $newName = AssetsHelper::prepareAssetName($newName, false); + $folder = $this->getFolderById($folderId); + + if (! $folder) { + throw new AssetOperationException(t('No folder exists with the ID "{id}"', [ + 'id' => $folderId, + ])); + } + + if (! $folder->parentId) { + throw new AssetOperationException(t("It\u{2019}s not possible to rename the top folder of a Volume.")); + } + + $conflictingFolder = $this->findFolder([ + 'parentId' => $folder->parentId, + 'name' => $newName, + ]); + + if ($conflictingFolder) { + throw new FsObjectExistsException(t('A folder with the name "{folderName}" already exists in the folder.', [ + 'folderName' => $newName, + ])); + } + + $parentFolderPath = dirname((string) $folder->path); + $newFolderPath = (($parentFolderPath && $parentFolderPath !== '.') ? $parentFolderPath.'/' : '').$newName.'/'; + + $volume = $folder->getVolume(); + + $this->renameDirectoryOnDisk($volume, rtrim((string) $folder->path, '/'), rtrim($newFolderPath, '/')); + $descendantFolders = $this->getAllDescendantFolders($folder); + + foreach ($descendantFolders as $descendantFolder) { + $descendantFolder->path = preg_replace('#^'.$folder->path.'#', $newFolderPath, (string) $descendantFolder->path); + $this->storeFolderModel($descendantFolder); + } + + $folder->name = $newName; + $folder->path = $newFolderPath; + $this->storeFolderModel($folder); + + return $newName; + } + + /** + * @param bool $deleteDir Whether the volume directory should be deleted along the record. + */ + public function deleteFoldersByIds(int|array $folderIds, bool $deleteDir = true): void + { + $allFolderIds = []; + + foreach ((array) $folderIds as $folderId) { + $folder = $this->getFolderById((int) $folderId); + + if (! $folder) { + continue; + } + + $allFolderIds[] = $folder->id; + $descendants = $this->getAllDescendantFolders($folder, withParent: false); + array_push($allFolderIds, ...array_map(fn (VolumeFolder $folder) => $folder->id, $descendants)); + + if ($folder->path && $deleteDir) { + $volume = $folder->getVolume(); + try { + $volume->sourceDisk()->deleteDirectory(trim($folder->path, '/')); + } catch (Throwable $exception) { + report($exception); + } + } + } + + $assetQuery = Asset::find()->folderId($allFolderIds); + $elementService = Craft::$app->getElements(); + + $assetQuery->each(function (Asset $asset) use ($deleteDir, $elementService) { + $asset->keepFileOnDelete = ! $deleteDir; + $elementService->deleteElement($asset, true); + }, 100); + + VolumeFolderModel::whereIn('id', $allFolderIds)->delete(); + } + + /** + * @throws FilesystemException + */ + public function ensureFolderByFullPathAndVolume(string $fullPath, Volume $volume, bool $justRecord = true): VolumeFolder + { + $parentFolder = $this->getRootFolderByVolumeId($volume->id); + $folderModel = $parentFolder; + $parentId = $parentFolder->id; + + $fullPath = trim($fullPath, '/\\'); + + if ($fullPath !== '') { + $parts = preg_split('/\\\\|\//', $fullPath); + $path = ''; + + while (($part = array_shift($parts)) !== null) { + $path .= $part.'/'; + + $parameters = new FolderCriteria([ + 'path' => $path, + 'volumeId' => $volume->id, + ]); + + if (($folderModel = $this->findFolder($parameters)) === null) { + $folderModel = new VolumeFolder; + $folderModel->volumeId = $volume->id; + $folderModel->parentId = $parentId; + $folderModel->name = $part; + $folderModel->path = $path; + $this->storeFolderModel($folderModel); + } + + if (! $justRecord && ! $volume->sourceDisk()->makeDirectory($path)) { + throw new FilesystemException("Unable to create directory at path: $path"); + } + + $parentId = $folderModel->id; + } + } + + return $folderModel; + } + + public function storeFolderModel(VolumeFolder $folder): void + { + if (! $folder->id) { + $model = new VolumeFolderModel; + } else { + $model = VolumeFolderModel::findOrFail($folder->id); + } + + $model->parentId = $folder->parentId; + $model->volumeId = $folder->volumeId; + $model->name = $folder->name; + $model->path = $folder->path; + $model->save(); + + $folder->id = $model->id; + $folder->uid = $model->uid; + } + + public function createFolderQuery(): Builder + { + return DB::table(Table::VOLUMEFOLDERS) + ->select(['id', 'parentId', 'volumeId', 'name', 'path', 'uid']); + } + + public function reset(): void + { + $this->foldersById = []; + $this->foldersByUid = []; + $this->rootFolders = []; + } + + private function applyFolderConditions(Builder $query, FolderCriteria $criteria): void + { + if ($criteria->id) { + $this->applyParam($query, 'id', $criteria->id); + } + + if ($criteria->volumeId) { + $this->applyParam($query, 'volumeId', $criteria->volumeId); + } + + if ($criteria->parentId) { + $this->applyParam($query, 'parentId', $criteria->parentId); + } + + if ($criteria->name) { + $this->applyParam($query, 'name', $criteria->name); + } + + if ($criteria->uid) { + $this->applyParam($query, 'uid', $criteria->uid); + } + + if ($criteria->path !== null) { + $this->applyParam($query, 'path', $criteria->path); + } + } + + private function applyParam(Builder $query, string $column, mixed $value): void + { + if ($value === ':empty:') { + $query->whereNull($column); + + return; + } + + if ($value === 'not :empty:') { + $query->whereNotNull($column); + + return; + } + + if (is_array($value)) { + $query->whereIn($column, $value); + + return; + } + + $query->where($column, $value); + } + + /** + * @param VolumeFolder[] $folders + * @return VolumeFolder[] + */ + private function buildFolderTree(array $folders): array + { + $tree = []; + /** @var VolumeFolder[] $referenceStore */ + $referenceStore = []; + + foreach ($folders as $folder) { + $folder->setChildren([]); + + if ($folder->parentId && isset($referenceStore[$folder->parentId])) { + $referenceStore[$folder->parentId]->addChild($folder); + } else { + $tree[] = $folder; + } + + $referenceStore[$folder->id] = $folder; + } + + return $tree; + } + + /** + * @throws FilesystemException + * @throws FsObjectNotFoundException + */ + private function renameDirectoryOnDisk(Volume $volume, string $sourcePath, string $targetPath): void + { + $sourcePath = trim($sourcePath, '/'); + $targetPath = trim($targetPath, '/'); + + $disk = $volume->sourceDisk(); + + if ($sourcePath === '' || ! $disk->directoryExists($sourcePath)) { + throw new FsObjectNotFoundException("No folder exists at path: $sourcePath"); + } + + if ($targetPath === '') { + throw new FilesystemException('New directory name cannot be empty.'); + } + + if ($targetPath === $sourcePath) { + return; + } + + if (! $disk->makeDirectory($targetPath)) { + throw new FilesystemException("Unable to create directory at path: $targetPath"); + } + + $directories = $disk->allDirectories($sourcePath); + usort($directories, fn (string $a, string $b) => substr_count($a, '/') <=> substr_count($b, '/')); + + foreach ($directories as $directory) { + $targetDirectory = preg_replace( + '/^'.preg_quote($sourcePath, '/').'(?=\/|$)/', + $targetPath, + trim((string) $directory, '/'), + 1, + ) ?? trim((string) $directory, '/'); + + if (! $disk->makeDirectory($targetDirectory)) { + throw new FilesystemException("Unable to create directory at path: $targetDirectory"); + } + } + + foreach ($disk->allFiles($sourcePath) as $file) { + $targetFile = preg_replace( + '/^'.preg_quote($sourcePath, '/').'(?=\/|$)/', + $targetPath, + trim((string) $file, '/'), + 1, + ) ?? trim((string) $file, '/'); + + if (! $disk->move($file, $targetFile)) { + throw new FilesystemException("Unable to move $file to $targetFile"); + } + } + + $disk->deleteDirectory($sourcePath); + } +} diff --git a/src/Asset/Validation/Rules/AssetLocationRule.php b/src/Asset/Validation/Rules/AssetLocationRule.php index 44dbb57e021..6af9681c773 100644 --- a/src/Asset/Validation/Rules/AssetLocationRule.php +++ b/src/Asset/Validation/Rules/AssetLocationRule.php @@ -5,11 +5,12 @@ namespace CraftCms\Cms\Asset\Validation\Rules; use Closure; -use Craft; use craft\helpers\Assets; use craft\helpers\Assets as AssetsHelper; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Cms; +use CraftCms\Cms\Support\Facades\Assets as AssetsService; +use CraftCms\Cms\Support\Facades\Folders; use Illuminate\Contracts\Validation\ValidationRule; use function CraftCms\Cms\t; @@ -48,7 +49,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - if (Craft::$app->getAssets()->getFolderById($folderId) === null) { + if (Folders::getFolderById($folderId) === null) { $fail(t('Invalid folder ID: {folderId}', ['folderId' => $folderId])); return; @@ -69,7 +70,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $filename = AssetsHelper::prepareAssetName($filename); - $suggestedFilename = Craft::$app->getAssets()->getNameReplacementInFolder($filename, $folderId); + $suggestedFilename = AssetsService::getNameReplacementInFolder($filename, $folderId); if ($suggestedFilename !== $filename) { $this->asset->{$this->conflictingFilenameAttribute} = $filename; diff --git a/src/Asset/Volumes.php b/src/Asset/Volumes.php index ce582617448..bead10b03aa 100644 --- a/src/Asset/Volumes.php +++ b/src/Asset/Volumes.php @@ -40,6 +40,8 @@ final class Volumes public function __construct( private readonly ProjectConfig $projectConfig, + private readonly Assets $assets, + private readonly Folders $folders, ) {} /** @return Collection */ @@ -103,7 +105,7 @@ public function getTemporaryVolume(): Volume 'name' => t('Temporary Uploads'), ]); - $fs = Craft::$app->getAssets()->getTempAssetUploadFs(); + $fs = $this->assets->getTempAssetUploadFs(); $volume->setFs($fs); return $volume; @@ -192,8 +194,7 @@ public function handleChangedVolume(ConfigEvent $event): void $volumeModel->save(); - $assetsService = Craft::$app->getAssets(); - $rootFolder = $assetsService->findFolder([ + $rootFolder = $this->folders->findFolder([ 'volumeId' => $volumeModel->id, 'parentId' => ':empty:', ]); @@ -207,7 +208,7 @@ public function handleChangedVolume(ConfigEvent $event): void ]); } else { $rootFolder->name = $volumeModel->name; - $assetsService->storeFolderRecord($rootFolder); + $this->folders->storeFolderModel($rootFolder); } DB::commit(); diff --git a/src/Console/Commands/Utils/DeleteEmptyVolumeFoldersCommand.php b/src/Console/Commands/Utils/DeleteEmptyVolumeFoldersCommand.php index 1d1551fb0f2..9cfea12dee5 100644 --- a/src/Console/Commands/Utils/DeleteEmptyVolumeFoldersCommand.php +++ b/src/Console/Commands/Utils/DeleteEmptyVolumeFoldersCommand.php @@ -4,9 +4,9 @@ namespace CraftCms\Cms\Console\Commands\Utils; -use Craft; use CraftCms\Cms\Console\CraftCommand; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\Volumes; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -66,7 +66,7 @@ public function handle(): int ); $this->components->task($message, function () use ($emptyFolderIds) { - Craft::$app->getAssets()->deleteFoldersByIds($emptyFolderIds); + Folders::deleteFoldersByIds($emptyFolderIds); }); return self::SUCCESS; diff --git a/src/Element/Queries/Concerns/Asset/QueriesAssetLocation.php b/src/Element/Queries/Concerns/Asset/QueriesAssetLocation.php index 0dfa2639b1a..36828137bf3 100644 --- a/src/Element/Queries/Concerns/Asset/QueriesAssetLocation.php +++ b/src/Element/Queries/Concerns/Asset/QueriesAssetLocation.php @@ -4,12 +4,12 @@ namespace CraftCms\Cms\Element\Queries\Concerns\Asset; -use Craft; use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Queries\AssetQuery; use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Support\Query; use Illuminate\Database\Query\Builder; @@ -96,8 +96,7 @@ protected function initQueriesAssetLocation(): void ->when( is_numeric($assetQuery->folderId) && $assetQuery->includeSubfolders, function (Builder $query) use ($assetQuery) { - $assetsService = Craft::$app->getAssets(); - $descendants = $assetsService->getAllDescendantFolders($assetsService->getFolderById($assetQuery->folderId)); + $descendants = Folders::getAllDescendantFolders(Folders::getFolderById($assetQuery->folderId)); $query->orWhereIn('assets.folderId', array_keys($descendants)); } diff --git a/src/Field/Assets.php b/src/Field/Assets.php index b19c2702453..11ddc6be07f 100644 --- a/src/Field/Assets.php +++ b/src/Field/Assets.php @@ -36,6 +36,8 @@ use CraftCms\Cms\Filesystem\Exceptions\InvalidSubpathException; use CraftCms\Cms\Filesystem\Filesystems\Temp; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Assets as AssetsService; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Support\Html; use GraphQL\Type\Definition\Type; @@ -455,8 +457,6 @@ public function beforeElementSave(ElementInterface $element, bool $isNew): bool // Figure out what we're working with and set up some initial variables. $isCanonical = $rootElement->getIsCanonical(); $query = $element->getFieldValue($this->handle); - $assetsService = Craft::$app->getAssets(); - $getUploadFolderId = function () use ($element, $isCanonical, &$_targetFolderId): int { return $_targetFolderId ?? ($_targetFolderId = $this->_uploadFolder($element, $isCanonical)->id); }; @@ -484,7 +484,7 @@ public function beforeElementSave(ElementInterface $element, bool $isNew): bool break; } - $uploadFolder = $assetsService->getFolderById($uploadFolderId); + $uploadFolder = Folders::getFolderById($uploadFolderId); $asset = new Asset; $asset->tempFilePath = $tempPath; $asset->setFilename($file['filename']); @@ -533,7 +533,6 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void // Figure out what we're working with and set up some initial variables. $isCanonical = $rootElement->getIsCanonical(); $query = $element->getFieldValue($this->handle); - $assetsService = Craft::$app->getAssets(); $getUploadFolderId = function () use ($element, $isCanonical, &$_targetFolderId): int { return $_targetFolderId ?? ($_targetFolderId = $this->_uploadFolder($element, $isCanonical)->id); @@ -554,14 +553,14 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void $rootRestrictedFolderId = $this->_uploadFolder($element, true, false)->id; } - $assetsToMove = array_filter($assets, function (Asset $asset) use ($rootRestrictedFolderId, $assetsService) { + $assetsToMove = array_filter($assets, function (Asset $asset) use ($rootRestrictedFolderId) { if ($asset->folderId === $rootRestrictedFolderId) { return false; } if (! $this->allowSubfolders) { return true; } - $rootRestrictedFolder = $assetsService->getFolderById($rootRestrictedFolderId); + $rootRestrictedFolder = Folders::getFolderById($rootRestrictedFolderId); return $asset->volumeId !== $rootRestrictedFolder->volumeId || @@ -570,19 +569,19 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void } else { // Find the files with temp sources and just move those. /** @var Asset[] $assetsToMove */ - $assetsToMove = $assetsService->createTempAssetQuery() + $assetsToMove = AssetsService::createTempAssetQuery() ->id(array_map(fn (Asset $asset) => $asset->id, $assets)) ->all(); } if (! empty($assetsToMove)) { - $uploadFolder = $assetsService->getFolderById($getUploadFolderId()); + $uploadFolder = Folders::getFolderById($getUploadFolderId()); // Resolve all conflicts by keeping both foreach ($assetsToMove as $asset) { $asset->avoidFilenameConflicts = true; try { - $assetsService->moveAsset($asset, $uploadFolder); + AssetsService::moveAsset($asset, $uploadFolder); } catch (FsObjectNotFoundException $e) { // Don't freak out about that. Log::warning('Couldn’t move asset because the file doesn’t exist: '.$e->getMessage()); @@ -638,7 +637,7 @@ public function getInputSources(?ElementInterface $element = null): array $sources = [$this->_sourceKeyByFolder($folder)]; if ($this->allowSubfolders) { - $userFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); + $userFolder = AssetsService::getUserTemporaryUploadFolder(); if ($userFolder->id !== $folder->id) { $sources[] = $this->_sourceKeyByFolder($userFolder); } @@ -853,7 +852,7 @@ private function _findFolder(string $sourceKey, ?string $subpath, ?ElementInterf throw new InvalidSubpathException($subpath); } - $folder = Craft::$app->getAssets()->ensureFolderByFullPathAndVolume($subpath, $volume); + $folder = Folders::ensureFolderByFullPathAndVolume($subpath, $volume); } return $folder; @@ -913,8 +912,6 @@ private function _uploadFolder( $settingName = fn () => t('Default Upload Location'); } - $assetsService = Craft::$app->getAssets(); - try { if (! $uploadVolume) { throw new InvalidFsException; @@ -936,7 +933,7 @@ private function _uploadFolder( ! $createDynamicFolders || ElementHelper::isDraft($element) ) { - return $assetsService->getUserTemporaryUploadFolder(); + return AssetsService::getUserTemporaryUploadFolder(); } // Existing element, so this is just a bad subpath diff --git a/src/GarbageCollection/Actions/RemoveEmptyTempFolders.php b/src/GarbageCollection/Actions/RemoveEmptyTempFolders.php index 399a934aad4..dc1ad0e2e36 100644 --- a/src/GarbageCollection/Actions/RemoveEmptyTempFolders.php +++ b/src/GarbageCollection/Actions/RemoveEmptyTempFolders.php @@ -4,8 +4,8 @@ namespace CraftCms\Cms\GarbageCollection\Actions; -use Craft; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Support\Facades\Folders; use Illuminate\Support\Facades\DB; use Tpetry\QueryExpressions\Language\Alias; @@ -26,7 +26,7 @@ function () { ->pluck('folders.id'); if ($emptyFolderIds->isNotEmpty()) { - Craft::$app->getAssets()->deleteFoldersByIds($emptyFolderIds->all()); + Folders::deleteFoldersByIds($emptyFolderIds->all()); } } ); diff --git a/src/Http/Controllers/Assets/ActionController.php b/src/Http/Controllers/Assets/ActionController.php new file mode 100644 index 00000000000..dfaefc427d9 --- /dev/null +++ b/src/Http/Controllers/Assets/ActionController.php @@ -0,0 +1,240 @@ +input('sourceId') ?? $request->input('assetId'); + + abort_if(empty($assetId), 400, 'Missing asset ID'); + + $asset = $this->assets->getAssetById($assetId); + + abort_if(! $asset, 400, "Invalid asset ID: $assetId"); + + $this->requireVolumePermissionByAsset('deleteAssets', $asset); + $this->requirePeerVolumePermissionByAsset('deletePeerAssets', $asset); + + $success = Craft::$app->getElements()->deleteElement($asset); + + if (! $success) { + return $this->asModelFailure( + $asset, + t('Couldn’t delete {type}.', [ + 'type' => Asset::lowerDisplayName(), + ]), + 'asset' + ); + } + + return $this->asModelSuccess( + $asset, + t('{type} deleted.', [ + 'type' => Asset::displayName(), + ]), + 'asset', + ); + } + + public function moveAsset(Request $request): Response + { + $request->validate([ + 'assetId' => ['required'], + ]); + + $assetId = $request->input('assetId'); + $asset = $this->assets->getAssetById($assetId); + + abort_if($asset === null, 400, 'The Asset cannot be found'); + + $folder = $this->folders->getFolderById($request->input('folderId', $asset->folderId)); + + abort_if($folder === null, 400, 'The folder cannot be found'); + + $filename = $request->input('filename') ?? $asset->getFilename(); + + $this->requireVolumePermissionByFolder('saveAssets', $folder); + $this->requireVolumePermissionByAsset('deleteAssets', $asset); + $this->requirePeerVolumePermissionByAsset('savePeerAssets', $asset); + $this->requirePeerVolumePermissionByAsset('deletePeerAssets', $asset); + + if ($request->boolean('force')) { + /** @var Asset|null $conflictingAsset */ + $conflictingAsset = Asset::find() + ->select(['elements.id']) + ->folderId($folder->id) + ->filename(Query::escapeParam($asset->getFilename())) + ->one(); + + if ($conflictingAsset) { + Craft::$app->getElements()->mergeElementsByIds($conflictingAsset->id, $asset->id); + } else { + $volume = $folder->getVolume(); + $volume->sourceDisk()->delete(rtrim($folder->path, '/').'/'.$asset->getFilename()); + } + } + + $result = $this->assets->moveAsset($asset, $folder, $filename); + + if (! $result) { + [, $filename] = AssetsHelper::parseFileLocation($asset->newLocation); + + return new JsonResponse([ + 'conflict' => $asset->errors()->first('newLocation'), + 'suggestedFilename' => $asset->suggestedFilename, + 'filename' => $filename, + 'assetId' => $asset->id, + ]); + } + + return $this->asSuccess(); + } + + public function downloadAsset(Request $request): Response + { + $request->validate([ + 'assetId' => ['required'], + ]); + + $assetIds = $request->input('assetId'); + /** @var Asset[] $assets */ + $assets = Asset::find() + ->id($assetIds) + ->all(); + + abort_if(empty($assets), 400, t('The asset you’re trying to download does not exist.')); + + foreach ($assets as $asset) { + $this->requireVolumePermissionByAsset('viewAssets', $asset); + $this->requirePeerVolumePermissionByAsset('viewPeerAssets', $asset); + } + + // If only one asset was selected, send it back unzipped + if (count($assets) === 1) { + $asset = reset($assets); + + return response()->streamDownload(function () use ($asset) { + $stream = $asset->getStream(); + fpassthru($stream); + if (is_resource($stream)) { + fclose($stream); + } + }, $asset->getFilename(), [ + 'Content-Type' => $asset->getMimeType(), + 'Content-Length' => $asset->size, + ]); + } + + // Otherwise create a zip of all the selected assets + $zipPath = Craft::$app->getPath()->getTempPath().'/'.Str::uuid()->toString().'.zip'; + $zip = new ZipArchive; + + abort_if($zip->open($zipPath, ZipArchive::CREATE) !== true, 500, 'Cannot create zip at '.$zipPath); + + maxPowerCaptain(); + + foreach ($assets as $asset) { + $path = $asset->getVolume()->name.'/'.$asset->getPath(); + $zip->addFromString($path, $asset->getContents()); + } + + $zip->close(); + + return response()->download($zipPath, 'assets.zip')->deleteFileAfterSend(); + } + + public function showInFolder(Request $request): Response + { + $request->validate([ + 'assetId' => ['required'], + ]); + + $assetId = $request->input('assetId'); + $asset = Asset::findOne($assetId); + + abort_if($asset === null, 400, "Invalid asset ID: $assetId"); + + $folder = $asset->getFolder(); + $sourcePath[] = $folder->getSourcePathInfo(); + + if ($request->expectsJson()) { + while (($parent = $folder->getParent()) !== null) { + $sourcePath[] = $parent->getSourcePathInfo(); + $folder = $parent; + } + + return new JsonResponse([ + 'filename' => $asset->filename, + 'sourcePath' => array_reverse($sourcePath), + ]); + } + + $uri = Str::start(UrlHelper::prependCpTrigger($sourcePath[0]['uri']), '/'); + $url = UrlHelper::urlWithParams($uri, [ + 'search' => $asset->filename, + 'includeSubfolders' => '0', + 'sourcePathStep' => "folder:$folder->uid", + ]); + + return redirect($url); + } + + public function moveInfo(Request $request): Response + { + $folderIds = $request->input('folderIds', []); + $assetIds = $request->input('assetIds', []); + + if (! empty($folderIds)) { + foreach ($folderIds as $folderId) { + $folder = $this->folders->getFolderById($folderId); + abort_if(! $folder, 400, "Invalid folder ID: $folderId"); + $descendants = $this->folders->getAllDescendantFolders($folder); + array_push($folderIds, ...array_keys($descendants)); + } + } + + $query = DB::table(Table::ASSETS) + ->whereIn('id', $assetIds) + ->orWhereIn('folderId', array_unique($folderIds)); + + $count = $query->count(); + $totalSize = (int) $query->sum('size'); + + return new JsonResponse([ + 'count' => $count, + 'totalSize' => $totalSize, + ]); + } +} diff --git a/src/Http/Controllers/Assets/FolderController.php b/src/Http/Controllers/Assets/FolderController.php new file mode 100644 index 00000000000..bcf2a6b48e5 --- /dev/null +++ b/src/Http/Controllers/Assets/FolderController.php @@ -0,0 +1,199 @@ +validate([ + 'parentId' => ['required', 'integer'], + 'folderName' => ['required', 'string'], + ]); + + $parentId = $request->integer('parentId'); + $folderName = AssetsHelper::prepareAssetName($request->input('folderName'), false); + + $parentFolder = $this->folders->findFolder(['id' => $parentId]); + + abort_if(! $parentFolder, 400, 'The parent folder cannot be found'); + + try { + $this->requireVolumePermissionByFolder('createFolders', $parentFolder); + + $folderModel = new VolumeFolder; + $folderModel->name = $folderName; + $folderModel->parentId = $parentId; + $folderModel->volumeId = $parentFolder->volumeId; + $folderModel->path = $parentFolder->path.$folderName.'/'; + + $this->folders->createFolder($folderModel); + + return $this->asSuccess(data: [ + 'folderName' => $folderModel->name, + 'folderUid' => $folderModel->uid, + 'folderId' => $folderModel->id, + ]); + } catch (UserException $exception) { + return $this->asFailure($exception->getMessage()); + } + } + + public function delete(Request $request): Response + { + $request->validate([ + 'folderId' => ['required', 'integer'], + ]); + + $folderId = $request->integer('folderId'); + $folder = $this->folders->getFolderById($folderId); + + abort_if(! $folder, 400, 'The folder cannot be found'); + + $this->requireVolumePermissionByFolder('deleteAssets', $folder); + $this->folders->deleteFoldersByIds($folderId); + + return $this->asSuccess(); + } + + public function rename(Request $request): Response + { + $request->validate([ + 'folderId' => ['required', 'integer'], + 'newName' => ['required', 'string'], + ]); + + $folderId = $request->integer('folderId'); + $newName = $request->input('newName'); + $folder = $this->folders->getFolderById($folderId); + + abort_if(! $folder, 400, 'The folder cannot be found'); + + $this->requireVolumePermissionByFolder('deleteAssets', $folder); + $this->requireVolumePermissionByFolder('createFolders', $folder); + + $newName = $this->folders->renameFolderById($folderId, $newName); + + return $this->asSuccess(data: ['newName' => $newName]); + } + + public function move(Request $request): Response + { + $request->validate([ + 'folderId' => ['required', 'integer'], + 'parentId' => ['required', 'integer'], + ]); + + $folderBeingMovedId = $request->integer('folderId'); + $newParentFolderId = $request->integer('parentId'); + $force = $request->boolean('force'); + $merge = ! $force && $request->boolean('merge'); + + $folderToMove = $this->folders->getFolderById($folderBeingMovedId); + $destinationFolder = $this->folders->getFolderById($newParentFolderId); + + abort_if(! $folderToMove, 400, 'The folder you are trying to move does not exist'); + abort_if(! $destinationFolder, 400, 'The destination folder does not exist'); + + $this->requireVolumePermissionByFolder('deleteAssets', $folderToMove); + $this->requireVolumePermissionByFolder('createFolders', $destinationFolder); + $this->requireVolumePermissionByFolder('saveAssets', $destinationFolder); + + $targetVolume = $destinationFolder->getVolume(); + + $existingFolder = $this->folders->findFolder([ + 'parentId' => $newParentFolderId, + 'name' => $folderToMove->name, + ]); + + if (! $existingFolder) { + $existingFolder = $targetVolume->sourceDisk()->directoryExists(Str::ltrim(Str::finish($destinationFolder->path, '/').$folderToMove->name, '/')); + } + + // If there's a conflict and `force`/`merge` flags weren't passed in, then stop + if ($existingFolder && ! $force && ! $merge) { + return $this->asJsonSuccess(data: [ + 'conflict' => t('Folder "{folder}" already exists at target location', ['folder' => $folderToMove->name]), + 'folderId' => $folderBeingMovedId, + 'parentId' => $newParentFolderId, + ]); + } + + $sourceTree = $this->folders->getAllDescendantFolders($folderToMove); + + if (! $existingFolder) { + $folderIdChanges = AssetsHelper::mirrorFolderStructure($folderToMove, $destinationFolder); + + $allSourceFolderIds = array_keys($sourceTree); + $allSourceFolderIds[] = $folderBeingMovedId; + $foundAssets = Asset::find() + ->folderId($allSourceFolderIds) + ->all(); + $fileTransferList = AssetsHelper::fileTransferList($foundAssets, $folderIdChanges); + } else { + $targetTreeMap = []; + + if ($existingFolder instanceof VolumeFolder) { + if ($force) { + try { + $this->folders->deleteFoldersByIds($existingFolder->id); + } catch (VolumeException $exception) { + report($exception); + + return $this->asFailure(t('Directories cannot be deleted while moving assets.')); + } + } else { + $targetTree = $this->folders->getAllDescendantFolders($existingFolder); + $targetPrefixLength = strlen($destinationFolder->path); + + foreach ($targetTree as $existingFolder) { + $targetTreeMap[substr((string) $existingFolder->path, $targetPrefixLength)] = $existingFolder->id; + } + } + } elseif ($force) { + $targetVolume->sourceDisk()->deleteDirectory(trim(rtrim($destinationFolder->path, '/').'/'.$folderToMove->name, '/')); + } + + $folderIdChanges = AssetsHelper::mirrorFolderStructure($folderToMove, $destinationFolder, $targetTreeMap); + + $allSourceFolderIds = array_keys($sourceTree); + $allSourceFolderIds[] = $folderBeingMovedId; + $foundAssets = Asset::find() + ->folderId($allSourceFolderIds) + ->all(); + $fileTransferList = AssetsHelper::fileTransferList($foundAssets, $folderIdChanges); + } + + $newFolderId = $folderIdChanges[$folderBeingMovedId] ?? null; + $newFolder = $this->folders->getFolderById($newFolderId); + + return $this->asSuccess(data: [ + 'transferList' => $fileTransferList, + 'newFolderUid' => $newFolder->uid, + 'newFolderId' => $newFolderId, + ]); + } +} diff --git a/src/Http/Controllers/Assets/IconController.php b/src/Http/Controllers/Assets/IconController.php new file mode 100644 index 00000000000..cb762371ac1 --- /dev/null +++ b/src/Http/Controllers/Assets/IconController.php @@ -0,0 +1,33 @@ +input('extension', $extension)); + + // iconSvg() may return a file path when the icon is already cached on disk + if (is_file($svg)) { + return response()->file($svg, [ + 'Content-Type' => 'image/svg+xml', + 'Cache-Control' => 'public, max-age=31536000', + ]); + } + + return response($svg, headers: [ + 'Content-Type' => 'image/svg+xml', + 'Cache-Control' => 'public, max-age=31536000', + ]); + } +} diff --git a/src/Http/Controllers/Assets/ImageEditorController.php b/src/Http/Controllers/Assets/ImageEditorController.php new file mode 100644 index 00000000000..adc0aed1a12 --- /dev/null +++ b/src/Http/Controllers/Assets/ImageEditorController.php @@ -0,0 +1,260 @@ +validate([ + 'assetId' => ['required'], + ]); + + $assetId = $request->input('assetId'); + $asset = $this->assets->getAssetById($assetId); + + abort_if(! $asset, 400, t('The asset you’re trying to edit does not exist.')); + + $focal = $asset->getHasFocalPoint() ? $asset->getFocalPoint() : null; + + return new JsonResponse([ + 'html' => template('_special/image_editor'), + 'focalPoint' => $focal, + ]); + } + + public function editImage(Request $request): Response + { + $request->validate([ + 'assetId' => ['required', 'integer'], + 'size' => ['required', 'integer'], + ]); + + $assetId = $request->integer('assetId'); + $size = $request->integer('size'); + + $asset = Asset::findOne($assetId); + + abort_if(! $asset, 400, 'The Asset cannot be found'); + + try { + $url = $this->assets->getImagePreviewUrl($asset, $size, $size); + + return redirect($url); + } catch (NotSupportedException) { + // just output the file contents + $path = ImageTransforms::getLocalImageSource($asset); + + return response()->file($path, [ + 'Content-Disposition' => 'inline; filename="'.$asset->getFilename().'"', + ]); + } + } + + public function save(Request $request): Response + { + $request->validate([ + 'assetId' => ['required'], + 'viewportRotation' => ['required', 'integer'], + 'imageRotation' => ['required', 'numeric'], + 'replace' => ['required'], + 'cropData' => ['required', 'array'], + ]); + + $assetId = $request->input('assetId'); + $viewportRotation = $request->integer('viewportRotation'); + $imageRotation = $request->float('imageRotation'); + $replace = $request->boolean('replace'); + $cropData = $request->input('cropData'); + $focalPoint = $request->input('focalPoint'); + $imageDimensions = $request->input('imageDimensions'); + $flipData = $request->input('flipData'); + $zoom = (float) $request->float('zoom', 1); + + // avoid a potential division by zero error + if (! $imageDimensions['width'] || ! $imageDimensions['height']) { + abort(400, t('Invalid imageDimensions param')); + } + + $asset = $this->assets->getAssetById($assetId); + + abort_if($asset === null, 400, 'The Asset cannot be found'); + + $folder = $asset->getFolder(); + + // Do what you want with your own photo. + if ($asset->id !== $request->user()->photoId) { + $this->requireVolumePermissionByAsset('editImages', $asset); + $this->requirePeerVolumePermissionByAsset('editPeerImages', $asset); + } + + // Verify parameter adequacy + abort_if( + ! in_array($viewportRotation, [0, 90, 180, 270], false), + 400, + t('Viewport rotation must be 0, 90, 180 or 270 degrees'), + ); + + if ( + is_array($cropData) && + array_diff(['offsetX', 'offsetY', 'height', 'width'], array_keys($cropData)) + ) { + abort(400, t('Invalid cropping parameters passed')); + } + + $transformer = new ImageTransformer; + + $originalImageWidth = $asset->width; + $originalImageHeight = $asset->height; + + $transformer->startImageEditing($asset); + + $imageCropped = ($cropData['width'] !== $imageDimensions['width'] || $cropData['height'] !== $imageDimensions['height']); + $imageRotated = $viewportRotation !== 0 || $imageRotation !== 0.0; + $imageFlipped = ! empty($flipData['x']) || ! empty($flipData['y']); + $imageChanged = $imageCropped || $imageRotated || $imageFlipped; + + if ($imageFlipped) { + $transformer->flipImage(! empty($flipData['x']), ! empty($flipData['y'])); + } + + $upscale = Cms::config()->upscaleImages; + Cms::config()->upscaleImages = true; + + if ($zoom !== 1.0) { + $transformer->scaleImage((int) ($originalImageWidth * $zoom), (int) ($originalImageHeight * $zoom)); + } + + Cms::config()->upscaleImages = $upscale; + + if ($imageRotated) { + $transformer->rotateImage($imageRotation + $viewportRotation); + } + + $imageCenterX = $transformer->getEditedImageWidth() / 2; + $imageCenterY = $transformer->getEditedImageHeight() / 2; + + $adjustmentRatio = min($originalImageWidth / $imageDimensions['width'], $originalImageHeight / $imageDimensions['height']); + $width = $cropData['width'] * $zoom * $adjustmentRatio; + $height = $cropData['height'] * $zoom * $adjustmentRatio; + $x = $imageCenterX + ($cropData['offsetX'] * $zoom * $adjustmentRatio) - $width / 2; + $y = $imageCenterY + ($cropData['offsetY'] * $zoom * $adjustmentRatio) - $height / 2; + + $focal = null; + + if ($focalPoint) { + $adjustmentRatio = min($originalImageWidth / $focalPoint['imageDimensions']['width'], $originalImageHeight / $focalPoint['imageDimensions']['height']); + $fx = $imageCenterX + ($focalPoint['offsetX'] * $zoom * $adjustmentRatio) - $x; + $fy = $imageCenterY + ($focalPoint['offsetY'] * $zoom * $adjustmentRatio) - $y; + + $focal = [ + 'x' => $fx / $width, + 'y' => $fy / $height, + ]; + } + + if ($imageCropped) { + $transformer->crop((int) $x, (int) $y, (int) $width, (int) $height); + } + + $finalImage = $imageChanged + ? $transformer->finishImageEditing() + : $transformer->cancelImageEditing(); + + $output = []; + + if ($replace) { + $oldFocal = $asset->getHasFocalPoint() ? $asset->getFocalPoint() : null; + $focalChanged = $focal !== $oldFocal; + $asset->setFocalPoint($focal); + + if ($focalChanged) { + $transforms = Craft::$app->getImageTransforms(); + $transforms->deleteCreatedTransformsForAsset($asset); + } + + // Only replace file if it changed, otherwise just save changed focal points + if ($imageChanged) { + $this->assets->replaceAssetFile($asset, $finalImage, $asset->getFilename(), $asset->getMimeType()); + } elseif ($focalChanged) { + Craft::$app->getElements()->saveElement($asset); + } + + return $this->asSuccess(data: $output); + } + + $newAsset = new Asset; + $newAsset->avoidFilenameConflicts = true; + $newAsset->setScenario(Asset::SCENARIO_CREATE); + + $newAsset->tempFilePath = $finalImage; + $newAsset->setFilename($asset->getFilename()); + $newAsset->newFolderId = $folder->id; + $newAsset->setVolumeId($folder->volumeId); + $newAsset->setFocalPoint($focal); + + // Don't validate required custom fields + Craft::$app->getElements()->saveElement($newAsset); + + $output['newAssetId'] = $newAsset->id; + + return $this->asSuccess(data: $output); + } + + public function updateFocalPoint(Request $request): Response + { + $request->validate([ + 'assetUid' => ['required', 'string'], + 'focal' => ['required'], + 'focalEnabled' => ['required'], + ]); + + $assetUid = $request->input('assetUid'); + $focalData = $request->input('focal'); + $focalEnabled = $request->input('focalEnabled'); + + // if focal point is disabled, set focal data to null + if ($focalEnabled === false) { + $focalData = null; + } + + /** @var Asset|null $asset */ + $asset = Asset::find()->uid($assetUid)->one(); + + abort_if(! $asset, 400, "Invalid asset UID: $assetUid"); + + $this->requireVolumePermissionByAsset('editImages', $asset); + $this->requirePeerVolumePermissionByAsset('editPeerImages', $asset); + + $asset->setFocalPoint($focalData); + Craft::$app->getElements()->saveElement($asset); + Craft::$app->getImageTransforms()->deleteCreatedTransformsForAsset($asset); + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Assets/IndexController.php b/src/Http/Controllers/Assets/IndexController.php new file mode 100644 index 00000000000..b97eae761b2 --- /dev/null +++ b/src/Http/Controllers/Assets/IndexController.php @@ -0,0 +1,73 @@ +input('defaultSource', $defaultSource)) { + return view('assets/_index', $variables); + } + + $defaultSourcePath = Arr::whereNotEmpty(explode('/', (string) $defaultSource)); + $volume = $this->volumes->getVolumeByHandle(array_shift($defaultSourcePath)); + + if (! $volume) { + return view('assets/_index', $variables); + } + + $variables['defaultSource'] = "volume:$volume->uid"; + + if (empty($defaultSourcePath)) { + return view('assets/_index', $variables); + } + + $subfolder = $this->folders->findFolder([ + 'volumeId' => $volume->id, + 'path' => sprintf('%s/', implode('/', $defaultSourcePath)), + ]); + + if (! $subfolder) { + return view('assets/_index', $variables); + } + + $sourcePath = []; + $folderChain = []; + + while ($subfolder) { + array_unshift($folderChain, $subfolder); + $subfolder = $subfolder->getParent(); + } + + foreach ($folderChain as $i => $folder) { + if ($i < count($folderChain) - 1) { + $folder->setHasChildren(true); + } + + $sourcePath[] = $folder->getSourcePathInfo(); + } + + $variables['defaultSourcePath'] = $sourcePath; + + return view('assets/_index', $variables); + } +} diff --git a/src/Http/Controllers/Assets/PreviewController.php b/src/Http/Controllers/Assets/PreviewController.php new file mode 100644 index 00000000000..f78b044a167 --- /dev/null +++ b/src/Http/Controllers/Assets/PreviewController.php @@ -0,0 +1,95 @@ +validate([ + 'assetId' => ['required', 'integer'], + 'width' => ['required', 'integer'], + 'height' => ['required', 'integer'], + ]); + + $asset = Asset::findOne($request->integer('assetId')); + + abort_if(! $asset, 400, 'Invalid asset ID: '.$request->integer('assetId')); + + return new JsonResponse([ + 'img' => $asset->getPreviewThumbImg($request->integer('width'), $request->integer('height')), + ]); + } + + public function previewFile(Request $request): Response + { + $request->validate([ + 'assetId' => ['required'], + 'requestId' => ['required'], + ]); + + $assetId = $request->input('assetId'); + $requestId = $request->input('requestId'); + + /** @var Asset|null $asset */ + $asset = Asset::find()->id($assetId)->one(); + + if (! $asset) { + return $this->asFailure(t('Asset not found with that id')); + } + + $previewHtml = null; + $previewHandler = $this->assets->getAssetPreviewHandler($asset); + $variables = []; + + if (($previewHandler instanceof ImagePreview) && $asset->id !== $request->user()->photoId) { + $variables['editFocal'] = true; + + try { + $this->requireVolumePermissionByAsset('editImages', $asset); + $this->requirePeerVolumePermissionByAsset('editPeerImages', $asset); + } catch (HttpException) { + $variables['editFocal'] = false; + } + } + + if ($previewHandler) { + try { + $previewHtml = $previewHandler->getPreviewHtml($variables); + } catch (NotSupportedException) { + // No big deal + } + } + + return $this->asSuccess(data: [ + 'previewHtml' => $previewHtml, + 'headHtml' => $this->htmlStack->headHtml(), + 'bodyHtml' => $this->htmlStack->bodyHtml(), + 'requestId' => $requestId, + ]); + } +} diff --git a/src/Http/Controllers/Assets/TransformController.php b/src/Http/Controllers/Assets/TransformController.php new file mode 100644 index 00000000000..2fd192f03c3 --- /dev/null +++ b/src/Http/Controllers/Assets/TransformController.php @@ -0,0 +1,153 @@ +integer('transformId')) { + $transformer = Craft::createObject(ImageTransformer::class); + $transformIndexModel = $transformer->getTransformIndexModelById($transformId); + abort_if(! $transformIndexModel, 400, "Invalid transform ID: $transformId"); + $assetId = $transformIndexModel->assetId; + try { + $transform = $transformIndexModel->getTransform(); + } catch (Throwable $e) { + abort(500, 'Image transform cannot be created.', ['exception' => $e]); + } + } else { + $assetId = $request->input('assetId'); + $handle = $request->input('handle'); + abort_if(! $assetId, 400, 'Missing assetId'); + abort_if(! is_string($handle), 400, 'Invalid transform handle.'); + try { + $transform = ImageTransforms::normalizeTransform($handle); + } catch (Throwable $e) { + abort(500, 'Image transform cannot be created.', ['exception' => $e]); + } + abort_if(! $transform, 400, "Invalid transform handle: $handle"); + $transformer = $transform->getImageTransformer(); + } + + $asset = Asset::findOne(['id' => $assetId]); + + abort_if(! $asset, 400, "Invalid asset ID: $assetId"); + + try { + $url = $transformer->getTransformUrl($asset, $transform, true); + } catch (Throwable $e) { + return $this->asBrokenImage($e); + } + + if ($request->expectsJson()) { + return new JsonResponse(['url' => $url]); + } + + return redirect($url); + } + + public function generateFallback(Request $request): Response + { + try { + $transform = Crypt::decrypt($request->input('transform')); + } catch (DecryptException) { + abort(400, 'Request contained an invalid transform param.'); + } + + [$assetId, $transformString] = explode(',', (string) $transform, 2); + + /** @var Asset|null $asset */ + $asset = Asset::find()->id($assetId)->one(); + abort_if(! $asset, 400, "Invalid asset ID: $assetId"); + + // If we're returning the original asset, and it's in a local FS, just read the file out directly + $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() + ); + + return response()->file($path, [ + 'Content-Disposition' => 'inline; filename="'.$asset->getFilename().'"', + 'Cache-Control' => 'public, max-age=31536000', + ]); + } + } + + if ($useOriginal) { + $ext = $asset->getExtension(); + } else { + /** @var ImageTransform $transform */ + $transform = Craft::createObject([ + 'class' => ImageTransform::class, + ...ImageTransforms::parseTransformString($transformString), + ]); + + $ext = $transform->format ?: ImageTransforms::detectTransformFormat($asset); + } + + $filename = sprintf('%s.%s', $asset->id, $ext); + $path = implode(DIRECTORY_SEPARATOR, [ + Craft::$app->getPath()->getImageTransformsPath(), + $transformString, + $filename, + ]); + + if (! file_exists($path) || filemtime($path) < ($asset->dateModified?->getTimestamp() ?? 0)) { + if ($useOriginal) { + $tempPath = $asset->getCopyOfFile(); + } else { + $tempPath = ImageTransforms::generateTransform($asset, $transform); + } + + FileHelper::createDirectory(dirname($path)); + rename($tempPath, $path); + } + + $responseFilename = sprintf('%s.%s', $asset->getFilename(false), $ext); + + return response()->file($path, [ + 'Content-Disposition' => 'inline; filename="'.$responseFilename.'"', + 'Cache-Control' => 'public, max-age=31536000', + ]); + } + + /** + * Sends a broken image response based on a given exception. + */ + private function asBrokenImage(?Throwable $e = null): Response + { + $statusCode = $e instanceof HttpException && $e->getStatusCode() ? $e->getStatusCode() : 500; + + return response()->file(Aliases::get('@appicons/broken-image.svg'), [ + 'Content-Type' => 'image/svg+xml', + ])->setStatusCode($statusCode); + } +} diff --git a/src/Http/Controllers/Assets/UploadController.php b/src/Http/Controllers/Assets/UploadController.php new file mode 100644 index 00000000000..f7bd139f824 --- /dev/null +++ b/src/Http/Controllers/Assets/UploadController.php @@ -0,0 +1,289 @@ +getElements(); + $uploadedFile = UploadedFile::getInstanceByName('assets-upload'); + + abort_if(! $uploadedFile, 400, 'No file was uploaded'); + + $folderId = $request->integer('folderId') ?: null; + $fieldId = $request->integer('fieldId') ?: null; + + abort_if(! $folderId && ! $fieldId, 400, 'No target destination provided for uploading'); + + $tempPath = $this->getUploadedFileTempPath($uploadedFile); + + if (empty($folderId)) { + /** @var AssetsField|null $field */ + $field = $this->fields->getFieldById($fieldId); + + abort_if(! $field instanceof AssetsField, 400, 'The field provided is not an Assets field'); + + if ($elementId = $request->input('elementId')) { + $siteId = $request->input('siteId') ?: null; + $element = $elementsService->getElementById($elementId, null, $siteId); + } else { + $element = null; + } + $folderId = $field->resolveDynamicPathToFolderId($element); + + $selectionCondition = $field->getSelectionCondition(); + if ($selectionCondition instanceof ElementCondition) { + $selectionCondition->referenceElement = $element; + } + } else { + $selectionCondition = null; + } + + abort_if(empty($folderId), 400, 'The target destination provided for uploading is not valid'); + + $folder = $this->folders->findFolder(['id' => $folderId]); + + abort_if(! $folder, 400, 'The target folder provided for uploading is not valid'); + + // Check the permissions to upload in the resolved folder. + $this->requireVolumePermissionByFolder('saveAssets', $folder); + + $filename = AssetsHelper::prepareAssetName($uploadedFile->name); + + if ($selectionCondition) { + $tempFolder = $this->assets->getUserTemporaryUploadFolder(); + + if ($folder->id !== $tempFolder->id) { + // upload to the user's temp folder initially, with a temp name + $originalFolder = $folder; + $originalFilename = $filename; + $folder = $tempFolder; + $filename = uniqid('asset', true).'.'.pathinfo($filename, PATHINFO_EXTENSION); + } + } + + $asset = new Asset; + $asset->tempFilePath = $tempPath; + $asset->setFilename($filename); + $asset->setMimeType(FileHelper::getMimeType($tempPath, checkExtension: false) ?? $uploadedFile->type); + $asset->newFolderId = $folder->id; + $asset->setVolumeId($folder->volumeId); + $asset->uploaderId = $request->user()->id; + $asset->avoidFilenameConflicts = true; + + if (isset($originalFilename)) { + $asset->title = AssetsHelper::filename2Title(pathinfo($originalFilename, PATHINFO_FILENAME)); + } + + $asset->setScenario(Asset::SCENARIO_CREATE); + $result = $elementsService->saveElement($asset); + + // In case of error, let user know about it. + if (! $result) { + return $this->asModelFailure($asset); + } + + if ($selectionCondition) { + if (! $selectionCondition->matchElement($asset)) { + // delete and reject it + $elementsService->deleteElement($asset, true); + + return $this->asFailure(t('{filename} isn’t selectable for this field.', [ + 'filename' => $uploadedFile->name, + ])); + } + + if (isset($originalFilename, $originalFolder)) { + // move it into the original target destination + $asset->newFilename = $originalFilename; + $asset->newFolderId = $originalFolder->id; + $asset->setScenario(Asset::SCENARIO_MOVE); + + if (! $elementsService->saveElement($asset)) { + return $this->asModelFailure($asset); + } + } + } + + // try to get uploaded asset's URL + $url = null; + try { + $url = $asset->getUrl(); + } catch (Throwable) { + // do nothing + } + + if ($asset->conflictingFilename !== null) { + $conflictingAsset = Asset::findOne(['folderId' => $folder->id, 'filename' => $asset->conflictingFilename]); + + return new JsonResponse([ + 'conflict' => t('A file with the name "{filename}" already exists.', ['filename' => $asset->conflictingFilename]), + 'assetId' => $asset->id, + 'filename' => $asset->conflictingFilename, + 'conflictingAssetId' => $conflictingAsset->id ?? null, + 'suggestedFilename' => $asset->suggestedFilename, + 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->getFs()->hasUrls) ? $conflictingAsset->getUrl() : null, + 'url' => $url, + ]); + } + + return $this->asSuccess(data: [ + 'filename' => $asset->getFilename(), + 'assetId' => $asset->id, + 'url' => $url, + ]); + } + + public function replaceFile(Request $request): Response + { + $assetId = $request->input('assetId'); + $sourceAssetId = $request->input('sourceAssetId'); + $targetFilename = $request->input('targetFilename'); + + if ( + $targetFilename && + (str_contains((string) $targetFilename, '/') || str_contains((string) $targetFilename, '\\')) + ) { + abort(400, 'Invalid filename: $targetFilename'); + } + + $uploadedFile = UploadedFile::getInstanceByName('replaceFile'); + + // Must have at least one existing asset (source or target). + // Must have either target asset or target filename. + // Must have either uploaded file or source asset. + if ((empty($assetId) && empty($sourceAssetId)) || + (empty($assetId) && empty($targetFilename)) || + ($uploadedFile === null && empty($sourceAssetId)) + ) { + abort(400, 'Incorrect combination of parameters.'); + } + + $sourceAsset = null; + $assetToReplace = null; + + if ($assetId && ! $assetToReplace = $this->assets->getAssetById($assetId)) { + abort(404, 'Asset not found.'); + } + + if ($sourceAssetId && ! $sourceAsset = $this->assets->getAssetById($sourceAssetId)) { + abort(404, 'Asset not found.'); + } + + $this->requireVolumePermissionByAsset('replaceFiles', $assetToReplace ?: $sourceAsset); + $this->requirePeerVolumePermissionByAsset('replacePeerFiles', $assetToReplace ?: $sourceAsset); + + // Handle the Element Action + if ($assetToReplace !== null && $uploadedFile) { + $tempPath = $this->getUploadedFileTempPath($uploadedFile); + $filename = AssetsHelper::prepareAssetName($uploadedFile->name); + $this->assets->replaceAssetFile($assetToReplace, $tempPath, $filename, $uploadedFile->type); + } elseif ($sourceAsset !== null) { + // Or replace using an existing Asset + if ($assetToReplace === null) { + // Make sure the extension didn't change + if (pathinfo((string) $targetFilename, PATHINFO_EXTENSION) !== $sourceAsset->getExtension()) { + abort(400, $targetFilename.' doesn\'t have the original file extension.'); + } + + /** @var Asset|null $assetToReplace */ + $assetToReplace = Asset::find() + ->select(['elements.id']) + ->folderId($sourceAsset->folderId) + ->filename(Db::escapeParam($targetFilename)) + ->one(); + } + + if (! empty($assetToReplace)) { + $tempPath = $sourceAsset->getCopyOfFile(); + $this->assets->replaceAssetFile($assetToReplace, $tempPath, $assetToReplace->getFilename(), $sourceAsset->getMimeType()); + Craft::$app->getElements()->deleteElement($sourceAsset); + } else { + $volume = $sourceAsset->getVolume(); + $volume->sourceDisk()->delete(rtrim((string) $sourceAsset->folderPath, '/').'/'.$targetFilename); + $sourceAsset->newFilename = $targetFilename; + Craft::$app->getElements()->saveElement($sourceAsset); + $assetId = $sourceAsset->id; + } + } + + $resultingAsset = $assetToReplace ?: $sourceAsset; + + return $this->asSuccess(data: [ + 'assetId' => $assetId, + 'filename' => $resultingAsset->getFilename(), + 'formattedSize' => $resultingAsset->getFormattedSize(0), + 'formattedSizeInBytes' => $resultingAsset->getFormattedSizeInBytes(false), + 'formattedDateUpdated' => I18N::getFormatter()->asDatetime($resultingAsset->dateUpdated, Formatter::FORMAT_WIDTH_SHORT), + 'dimensions' => $resultingAsset->getDimensions(), + 'updatedTimestamp' => $resultingAsset->dateUpdated->getTimestamp(), + 'resultingUrl' => $resultingAsset->getUrl(), + ]); + } + + /** + * @throws UploadFailedException + */ + private function getUploadedFileTempPath(UploadedFile $uploadedFile): string + { + if ($uploadedFile->getHasError()) { + throw new UploadFailedException($uploadedFile->error); + } + + // Make sure the file extension is allowed + $allowedExtensions = Cms::config()->allowedFileExtensions; + $extension = strtolower(pathinfo($uploadedFile->name, PATHINFO_EXTENSION)); + + if (! in_array($extension, $allowedExtensions, true)) { + throw new AssetDisallowedExtensionException(t('"{extension}" is not an allowed file extension.', [ + 'extension' => $extension, + ])); + } + + // Move the uploaded file to the temp folder + $tempPath = $uploadedFile->saveAsTempFile(); + + if ($tempPath === false) { + throw new UploadFailedException(UPLOAD_ERR_CANT_WRITE); + } + + return $tempPath; + } +} diff --git a/src/Http/Controllers/Utilities/AssetIndexesController.php b/src/Http/Controllers/Utilities/AssetIndexesController.php index d19d4214b6b..2bdfe7c5398 100644 --- a/src/Http/Controllers/Utilities/AssetIndexesController.php +++ b/src/Http/Controllers/Utilities/AssetIndexesController.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Asset\AssetIndexer; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Utility\Utilities; use CraftCms\Cms\Utility\Utilities\AssetIndexes; use Illuminate\Http\Request; @@ -193,7 +194,7 @@ public function finishIndexingSession(Request $request): Response if (! empty($deleteFolders)) { // if listEmptyFolders was set to true, delete the directories too, so that they don't pop back up on next indexing - Craft::$app->getAssets()->deleteFoldersByIds($deleteFolders, $session->listEmptyFolders ?? false); + Folders::deleteFoldersByIds($deleteFolders, $session->listEmptyFolders ?? false); } if (! empty($deleteFiles)) { diff --git a/src/Support/Facades/Assets.php b/src/Support/Facades/Assets.php new file mode 100644 index 00000000000..240caebeaa5 --- /dev/null +++ b/src/Support/Facades/Assets.php @@ -0,0 +1,34 @@ + findFolders(mixed $criteria = []) + * @method static \CraftCms\Cms\Asset\Data\VolumeFolder|null findFolder(mixed $criteria = []) + * @method static array getAllDescendantFolders(\CraftCms\Cms\Asset\Data\VolumeFolder $parentFolder, string $orderBy = 'path', bool $withParent = true, bool $asTree = false) + * @method static \CraftCms\Cms\Asset\Data\VolumeFolder|null getRootFolderByVolumeId(int $volumeId) + * @method static int getTotalFolders(mixed $criteria) + * @method static bool foldersExist(mixed $criteria = null) + * @method static void createFolder(\CraftCms\Cms\Asset\Data\VolumeFolder $folder) + * @method static string renameFolderById(int $folderId, string $newName) + * @method static void deleteFoldersByIds(int|array $folderIds, bool $deleteDir = true) + * @method static \CraftCms\Cms\Asset\Data\VolumeFolder ensureFolderByFullPathAndVolume(string $fullPath, \CraftCms\Cms\Asset\Data\Volume $volume, bool $justRecord = true) + * @method static void storeFolderRecord(\CraftCms\Cms\Asset\Data\VolumeFolder $folder) + * @method static \Illuminate\Database\Query\Builder createFolderQuery() + * @method static void reset() + * + * @see \CraftCms\Cms\Asset\Folders + */ +final class Folders extends Facade +{ + #[Override] + protected static function getFacadeAccessor(): string + { + return \CraftCms\Cms\Asset\Folders::class; + } +} diff --git a/src/User/Elements/User.php b/src/User/Elements/User.php index 1d411f9d3f6..efb13b5698d 100644 --- a/src/User/Elements/User.php +++ b/src/User/Elements/User.php @@ -40,6 +40,7 @@ use CraftCms\Cms\Shared\Enums\Color; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Assets as AssetsService; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\InputNamespace; @@ -1276,7 +1277,7 @@ protected function thumbUrl(int $size): ?string $photo = $this->getPhoto(); if ($photo) { - return Craft::$app->getAssets()->getThumbUrl($photo, $size, iconFallback: false); + return AssetsService::getThumbUrl($photo, $size, iconFallback: false); } return null; @@ -1865,7 +1866,7 @@ public function getPhoto(): ?Asset return null; } - $this->_photo = Craft::$app->getAssets()->getAssetById($this->photoId) ?? false; + $this->_photo = AssetsService::getAssetById($this->photoId) ?? false; } return $this->_photo ?: null; diff --git a/src/User/Users.php b/src/User/Users.php index 005a68fb291..644728c2b89 100644 --- a/src/User/Users.php +++ b/src/User/Users.php @@ -26,6 +26,8 @@ use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\ProjectConfig\ProjectConfigHelper; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Assets as AssetsService; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; @@ -340,18 +342,17 @@ public function saveUserPhoto( throw new ImageException(t('User photo must be an image that Craft can manipulate.')); } - $assetsService = Craft::$app->getAssets(); $photoId = $user->photoId; event($event = new SavingUserPhoto($user, $photoId)); // If the photo exists, just replace the file. - if ($event->photoId && ($photo = Craft::$app->getAssets()->getAssetById($event->photoId)) !== null) { - $assetsService->replaceAssetFile($photo, $fileLocation, $filename, $mimeType); + if ($event->photoId && ($photo = AssetsService::getAssetById($event->photoId)) !== null) { + AssetsService::replaceAssetFile($photo, $fileLocation, $filename, $mimeType); } else { $volume = $this->userPhotoVolume(); $folderId = $this->userPhotoFolderId($user, $volume); - $filename = $assetsService->getNameReplacementInFolder($filename, $folderId); + $filename = AssetsService::getNameReplacementInFolder($filename, $folderId); $photo = new Asset; $photo->setScenario(Asset::SCENARIO_CREATE); @@ -435,7 +436,7 @@ private function userPhotoFolderId(User $user, Volume $volume): int } } - return Craft::$app->getAssets()->ensureFolderByFullPathAndVolume($subpath, $volume)->id; + return Folders::ensureFolderByFullPathAndVolume($subpath, $volume)->id; } /** diff --git a/tests/Feature/Asset/AssetsTest.php b/tests/Feature/Asset/AssetsTest.php new file mode 100644 index 00000000000..5ec8a75221b --- /dev/null +++ b/tests/Feature/Asset/AssetsTest.php @@ -0,0 +1,205 @@ +assets = app(Assets::class); + + config()->set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/assets-test/test-disk'), + ]); +}); + +it('is a singleton', function () { + expect(AssetsFacade::getFacadeRoot())->toBe(app(Assets::class)); + expect($this->assets)->toBe(app(Assets::class)); +}); + +it('can get an asset by id', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $assetModel = AssetModel::factory()->create([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + ]); + + $asset = $this->assets->getAssetById($assetModel->id); + + expect($asset)->toBeInstanceOf(Asset::class); +}); + +it('returns null for non-existent asset id', function () { + expect($this->assets->getAssetById(999))->toBeNull(); +}); + +it('can get total assets', function () { + expect($this->assets->getTotalAssets())->toBe(0); + + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + AssetModel::factory()->count(3)->create([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + ]); + + expect($this->assets->getTotalAssets())->toBe(3); +}); + +it('dispatches DefineThumbUrl event in getThumbUrl', function () { + Event::fake([DefineThumbUrl::class]); + + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'test.txt', + 'kind' => 'text', + ]); + + $this->assets->getThumbUrl($asset, 100); + + Event::assertDispatched(fn (DefineThumbUrl $event) => $event->asset->id === $asset->id + && $event->width === 100 + && $event->height === 100); +}); + +it('uses DefineThumbUrl event url when set', function () { + Event::listen(DefineThumbUrl::class, function (DefineThumbUrl $event) { + $event->url = 'https://example.com/custom-thumb.jpg'; + }); + + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'test.jpg', + 'kind' => 'image', + ]); + + $url = $this->assets->getThumbUrl($asset, 100); + + expect($url)->toBe('https://example.com/custom-thumb.jpg'); +}); + +it('dispatches RegisterPreviewHandler event', function () { + Event::fake([RegisterPreviewHandler::class]); + + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'test.txt', + 'kind' => 'text', + ]); + + $this->assets->getAssetPreviewHandler($asset); + + Event::assertDispatched(RegisterPreviewHandler::class); +}); + +it('returns default preview handler for known asset kinds', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + + $textAsset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'test.txt', + 'kind' => Asset::KIND_TEXT, + ]); + + $handler = $this->assets->getAssetPreviewHandler($textAsset); + + expect($handler)->toBeInstanceOf(Text::class); +}); + +it('returns null preview handler for unknown asset kinds', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'test.xyz', + 'kind' => 'unknown', + ]); + + $handler = $this->assets->getAssetPreviewHandler($asset); + + expect($handler)->toBeNull(); +}); + +it('can get temp asset upload filesystem', function () { + $fs = $this->assets->getTempAssetUploadFs(); + + expect($fs)->toBeInstanceOf(FsInterface::class); +}); + +it('can create a temp asset query', function () { + $query = $this->assets->createTempAssetQuery(); + + expect($query)->toBeInstanceOf(AssetQuery::class); +}); + +it('can get name replacement in folder when no conflict', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + + app()->forgetInstance(Folders::class); + + $rootFolder = app(Folders::class)->getRootFolderByVolumeId($volume->id); + + $result = $this->assets->getNameReplacementInFolder('unique-file.jpg', $rootFolder->id); + + expect($result)->toBe('unique-file.jpg'); +}); + +it('dispatches BeforeReplaceAsset event with filename', function () { + Event::fake([BeforeReplaceAsset::class]); + + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $folder = VolumeFolderModel::factory()->create(['volumeId' => $volume->id]); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + ]); + + $tempFile = tempnam(sys_get_temp_dir(), 'craft_test_'); + file_put_contents($tempFile, 'test content'); + + try { + $this->assets->replaceAssetFile($asset, $tempFile, 'new-filename.jpg'); + } catch (Throwable) { + // The save may fail due to missing filesystem setup, but the event should still fire + } + + Event::assertDispatched(fn (BeforeReplaceAsset $event) => $event->asset->id === $asset->id + && $event->filename === 'new-filename.jpg'); + + @unlink($tempFile); +}); + +it('resets caches', function () { + $this->assets->reset(); + + // No error means the reset worked + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/Asset/FoldersTest.php b/tests/Feature/Asset/FoldersTest.php new file mode 100644 index 00000000000..2c3f101cf29 --- /dev/null +++ b/tests/Feature/Asset/FoldersTest.php @@ -0,0 +1,466 @@ +folders = app(Folders::class); + + config()->set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/folders-test/test-disk'), + ]); +}); + +it('is a singleton', function () { + expect(FoldersFacade::getFacadeRoot())->toBe(app(Folders::class)); + expect($this->folders)->toBe(app(Folders::class)); +}); + +it('can get a folder by id', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $model = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Test Folder', + 'path' => 'test-folder/', + ]); + + $folder = $this->folders->getFolderById($model->id); + + expect($folder)->toBeInstanceOf(VolumeFolder::class); + expect($folder->name)->toBe('Test Folder'); + expect($folder->path)->toBe('test-folder/'); +}); + +it('returns null for non-existent folder id', function () { + expect($this->folders->getFolderById(999))->toBeNull(); +}); + +it('caches folder by id lookups', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $model = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Cached Folder', + ]); + + $first = $this->folders->getFolderById($model->id); + $second = $this->folders->getFolderById($model->id); + + expect($first)->toBe($second); +}); + +it('can get a folder by uid', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $model = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'UID Folder', + ]); + + $folder = $this->folders->getFolderByUid($model->uid); + + expect($folder)->toBeInstanceOf(VolumeFolder::class); + expect($folder->name)->toBe('UID Folder'); +}); + +it('returns null for non-existent folder uid', function () { + expect($this->folders->getFolderByUid('non-existent-uid'))->toBeNull(); +}); + +it('can find folders by criteria', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Folder A', + 'path' => 'folder-a/', + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Folder B', + 'path' => 'folder-b/', + ]); + + $folders = $this->folders->findFolders(['volumeId' => $volume->id]); + + expect($folders)->toHaveCount(2); + expect($folders->pluck('name')->sort()->values()->all())->toBe(['Folder A', 'Folder B']); +}); + +it('can find folders with string order criteria', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Bravo', + 'path' => 'bravo/', + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Alpha', + 'path' => 'alpha/', + ]); + + $folders = $this->folders->findFolders([ + 'volumeId' => $volume->id, + 'order' => 'name asc', + ]); + + expect($folders->first()->name)->toBe('Alpha'); +}); + +it('can find folders with descending order', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Alpha', + 'path' => 'alpha/', + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Bravo', + 'path' => 'bravo/', + ]); + + $folders = $this->folders->findFolders([ + 'volumeId' => $volume->id, + 'order' => 'name desc', + ]); + + expect($folders->first()->name)->toBe('Bravo'); +}); + +it('can find folders with array order criteria', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Bravo', + 'path' => 'bravo/', + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Alpha', + 'path' => 'alpha/', + ]); + + $folders = $this->folders->findFolders([ + 'volumeId' => $volume->id, + 'order' => ['name' => SORT_ASC], + ]); + + expect($folders->first()->name)->toBe('Alpha'); +}); + +it('can find a single folder', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $model = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Single', + ]); + + $folder = $this->folders->findFolder(['volumeId' => $volume->id, 'name' => 'Single']); + + expect($folder)->toBeInstanceOf(VolumeFolder::class); + expect($folder->id)->toBe($model->id); +}); + +it('returns null when findFolder matches nothing', function () { + expect($this->folders->findFolder(['name' => 'nonexistent']))->toBeNull(); +}); + +it('can find folder by path containing a comma', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'my,folder', + 'path' => 'my,folder/', + ]); + + $folder = $this->folders->findFolder([ + 'volumeId' => $volume->id, + 'path' => 'my,folder/', + ]); + + expect($folder)->not->toBeNull(); + expect($folder->name)->toBe('my,folder'); +}); + +it('can get root folder by volume id', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + + app()->forgetInstance(Folders::class); + $this->folders = app(Folders::class); + + $rootFolder = $this->folders->getRootFolderByVolumeId($volume->id); + + expect($rootFolder)->toBeInstanceOf(VolumeFolder::class); + expect($rootFolder->volumeId)->toBe($volume->id); + expect($rootFolder->parentId)->toBeNull(); + expect($rootFolder->path)->toBe(''); +}); + +it('creates root folder if it does not exist', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + + expect(VolumeFolderModel::where('volumeId', $volume->id)->count())->toBe(0); + + app()->forgetInstance(Folders::class); + $this->folders = app(Folders::class); + + $rootFolder = $this->folders->getRootFolderByVolumeId($volume->id); + + expect($rootFolder)->not->toBeNull(); + expect(VolumeFolderModel::where('volumeId', $volume->id)->count())->toBe(1); +}); + +it('returns null for root folder of non-existent volume', function () { + expect($this->folders->getRootFolderByVolumeId(999))->toBeNull(); +}); + +it('can get total folders', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->count(3)->create(['volumeId' => $volume->id]); + + expect($this->folders->getTotalFolders(['volumeId' => $volume->id]))->toBe(3); +}); + +it('can check if folders exist', function () { + expect($this->folders->foldersExist(['name' => 'nonexistent']))->toBeFalse(); + + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + VolumeFolderModel::factory()->create(['volumeId' => $volume->id, 'name' => 'exists']); + + expect($this->folders->foldersExist(['volumeId' => $volume->id]))->toBeTrue(); +}); + +it('can get all descendant folders', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $root = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Root', + 'path' => '', + 'parentId' => null, + ]); + $child = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Child', + 'path' => 'child/', + 'parentId' => $root->id, + ]); + $grandchild = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Grandchild', + 'path' => 'child/grandchild/', + 'parentId' => $child->id, + ]); + + $rootData = new VolumeFolder([ + 'id' => $root->id, + 'volumeId' => $volume->id, + 'name' => 'Root', + 'path' => '', + 'parentId' => null, + ]); + + $descendants = $this->folders->getAllDescendantFolders($rootData); + + expect($descendants)->toHaveCount(2); + expect(array_keys($descendants))->toContain($child->id, $grandchild->id); +}); + +it('can get descendant folders as tree', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $root = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Root', + 'path' => '', + 'parentId' => null, + ]); + $child = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Child', + 'path' => 'child/', + 'parentId' => $root->id, + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Grandchild', + 'path' => 'child/grandchild/', + 'parentId' => $child->id, + ]); + + $rootData = new VolumeFolder([ + 'id' => $root->id, + 'volumeId' => $volume->id, + 'name' => 'Root', + 'path' => '', + 'parentId' => null, + ]); + + $tree = $this->folders->getAllDescendantFolders($rootData, asTree: true); + + expect($tree)->toHaveCount(1); + $childNode = array_first($tree); + expect($childNode->name)->toBe('Child'); + expect($childNode->getChildren())->toHaveCount(1); + expect($childNode->getChildren()[0]->name)->toBe('Grandchild'); +}); + +it('can exclude parent from descendant folders', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $root = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Root', + 'path' => '', + 'parentId' => null, + ]); + $child = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Child', + 'path' => 'child/', + 'parentId' => $root->id, + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Grandchild', + 'path' => 'child/grandchild/', + 'parentId' => $child->id, + ]); + + $childData = new VolumeFolder([ + 'id' => $child->id, + 'volumeId' => $volume->id, + 'name' => 'Child', + 'path' => 'child/', + 'parentId' => $root->id, + ]); + + $withParent = $this->folders->getAllDescendantFolders($childData, withParent: true); + $withoutParent = $this->folders->getAllDescendantFolders($childData, withParent: false); + + expect($withParent)->toHaveKey($child->id); + expect($withoutParent)->not->toHaveKey($child->id); + expect($withoutParent)->toHaveCount(count($withParent) - 1); +}); + +it('can store a new folder record', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + + $folder = new VolumeFolder; + $folder->volumeId = $volume->id; + $folder->name = 'New Folder'; + $folder->path = 'new-folder/'; + + $this->folders->storeFolderModel($folder); + + expect($folder->id)->not->toBeNull(); + expect($folder->uid)->not->toBeNull(); + + $model = VolumeFolderModel::find($folder->id); + expect($model->name)->toBe('New Folder'); + expect($model->path)->toBe('new-folder/'); +}); + +it('can update an existing folder record', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $model = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Original', + 'path' => 'original/', + ]); + + $folder = new VolumeFolder([ + 'id' => $model->id, + 'volumeId' => $volume->id, + 'name' => 'Updated', + 'path' => 'updated/', + ]); + + $this->folders->storeFolderModel($folder); + + $model->refresh(); + expect($model->name)->toBe('Updated'); + expect($model->path)->toBe('updated/'); +}); + +it('can ensure folder by full path and volume', function () { + $volumeModel = Volume::factory()->create(['fs' => 'disk:test-disk']); + + app()->forgetInstance(Folders::class); + app()->forgetInstance(Volumes::class); + $this->folders = app(Folders::class); + + $volume = app(Volumes::class)->getVolumeById($volumeModel->id); + + $folder = $this->folders->ensureFolderByFullPathAndVolume('foo/bar/baz', $volume); + + expect($folder)->toBeInstanceOf(VolumeFolder::class); + expect($folder->name)->toBe('baz'); + expect($folder->path)->toBe('foo/bar/baz/'); + + $allFolders = $this->folders->findFolders(['volumeId' => $volume->id]); + // root + foo + bar + baz = 4 + expect($allFolders)->toHaveCount(4); +}); + +it('reuses existing folders in ensureFolderByFullPathAndVolume', function () { + $volumeModel = Volume::factory()->create(['fs' => 'disk:test-disk']); + + app()->forgetInstance(Folders::class); + app()->forgetInstance(Volumes::class); + $this->folders = app(Folders::class); + + $volume = app(Volumes::class)->getVolumeById($volumeModel->id); + + $first = $this->folders->ensureFolderByFullPathAndVolume('foo/bar', $volume); + $second = $this->folders->ensureFolderByFullPathAndVolume('foo/bar', $volume); + + expect($first->id)->toBe($second->id); +}); + +it('can apply :empty: criteria for null columns', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $root = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Root', + 'parentId' => null, + ]); + VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Has Parent', + 'parentId' => $root->id, + ]); + + $rootFolders = $this->folders->findFolders([ + 'volumeId' => $volume->id, + 'parentId' => ':empty:', + ]); + + expect($rootFolders)->toHaveCount(1); + expect($rootFolders->first()->name)->toBe('Root'); +}); + +it('resets caches', function () { + $volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $model = VolumeFolderModel::factory()->create([ + 'volumeId' => $volume->id, + 'name' => 'Cached', + ]); + + $this->folders->getFolderById($model->id); + $this->folders->reset(); + + // After reset, it should re-query (no error means cache was cleared) + $folder = $this->folders->getFolderById($model->id); + expect($folder->name)->toBe('Cached'); +}); + +it('creates a folder query builder', function () { + $query = $this->folders->createFolderQuery(); + + expect($query)->toBeInstanceOf(Builder::class); +}); diff --git a/tests/Feature/Http/Controllers/Assets/ActionControllerTest.php b/tests/Feature/Http/Controllers/Assets/ActionControllerTest.php new file mode 100644 index 00000000000..48fc8e904b0 --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/ActionControllerTest.php @@ -0,0 +1,145 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/action-controller-test/test-disk'), + ]); + + $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); +}); + +describe('deleteAsset', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ActionController::class, 'deleteAsset']), [ + 'assetId' => 1, + ])->assertUnauthorized(); + }); + + it('can delete an asset', function () { + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + ]); + + postJson(action([ActionController::class, 'deleteAsset']), [ + 'assetId' => $asset->id, + ])->assertOk(); + }); + + it('returns 400 for missing asset id', function () { + postJson(action([ActionController::class, 'deleteAsset'])) + ->assertStatus(400); + }); +}); + +describe('moveAsset', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ActionController::class, 'moveAsset']), [ + 'assetId' => 1, + ])->assertUnauthorized(); + }); + + it('validates move asset input', function () { + postJson(action([ActionController::class, 'moveAsset'])) + ->assertJsonValidationErrors(['assetId']); + }); +}); + +describe('downloadAsset', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ActionController::class, 'downloadAsset']), [ + 'assetId' => 1, + ])->assertUnauthorized(); + }); + + it('validates download asset input', function () { + postJson(action([ActionController::class, 'downloadAsset'])) + ->assertJsonValidationErrors(['assetId']); + }); +}); + +describe('showInFolder', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ActionController::class, 'showInFolder']), [ + 'assetId' => 1, + ])->assertUnauthorized(); + }); + + it('validates show in folder input', function () { + postJson(action([ActionController::class, 'showInFolder'])) + ->assertJsonValidationErrors(['assetId']); + }); + + it('can show asset in folder', function () { + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'findme.jpg', + ]); + + postJson(action([ActionController::class, 'showInFolder']), [ + 'assetId' => $asset->id, + ]) + ->assertOk() + ->assertJsonStructure(['filename', 'sourcePath']) + ->assertJsonPath('filename', 'findme.jpg'); + }); +}); + +describe('moveInfo', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ActionController::class, 'moveInfo'])) + ->assertUnauthorized(); + }); + + it('can get move info', function () { + $asset = AssetModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + ]); + + postJson(action([ActionController::class, 'moveInfo']), [ + 'assetIds' => [$asset->id], + ]) + ->assertOk() + ->assertJsonStructure(['count', 'totalSize']); + }); + + it('returns count and total size for folder ids', function () { + AssetModel::factory()->count(3)->create([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + ]); + + postJson(action([ActionController::class, 'moveInfo']), [ + 'folderIds' => [$this->folder->id], + ]) + ->assertOk() + ->assertJsonPath('count', 3); + }); +}); diff --git a/tests/Feature/Http/Controllers/Assets/FolderControllerTest.php b/tests/Feature/Http/Controllers/Assets/FolderControllerTest.php new file mode 100644 index 00000000000..b89b46ed661 --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/FolderControllerTest.php @@ -0,0 +1,127 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/folder-controller-test/test-disk'), + ]); + + $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); +}); + +it('requires authentication', function () { + auth()->logout(); + + postJson(action([FolderController::class, 'create']), [ + 'parentId' => $this->folder->id, + 'folderName' => 'New Folder', + ])->assertUnauthorized(); +}); + +it('can create a folder', function () { + postJson(action([FolderController::class, 'create']), [ + 'parentId' => $this->folder->id, + 'folderName' => 'New Folder', + ]) + ->assertOk() + ->assertJsonPath('folderName', 'New-Folder'); +}); + +it('validates create folder input', function () { + postJson(action([FolderController::class, 'create'])) + ->assertJsonValidationErrors(['parentId', 'folderName']); +}); + +it('can delete a folder', function () { + $subfolder = VolumeFolderModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'parentId' => $this->folder->id, + 'path' => $this->folder->path.'subfolder/', + ]); + + postJson(action([FolderController::class, 'delete']), [ + 'folderId' => $subfolder->id, + ])->assertOk(); +}); + +it('validates delete folder input', function () { + postJson(action([FolderController::class, 'delete'])) + ->assertJsonValidationErrors(['folderId']); +}); + +it('validates rename folder input', function () { + postJson(action([FolderController::class, 'rename'])) + ->assertJsonValidationErrors(['folderId', 'newName']); +}); + +it('can rename a folder', function () { + // Create the actual directory on disk so the rename operation succeeds + $diskRoot = storage_path('framework/testing/folder-controller-test/test-disk'); + $subfolderPath = $diskRoot.'/subfolder'; + if (! is_dir($subfolderPath)) { + mkdir($subfolderPath, 0755, true); + } + + $subfolder = VolumeFolderModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'parentId' => $this->folder->id, + 'path' => 'subfolder/', + 'name' => 'subfolder', + ]); + + postJson(action([FolderController::class, 'rename']), [ + 'folderId' => $subfolder->id, + 'newName' => 'Renamed Folder', + ]) + ->assertOk() + ->assertJsonPath('newName', 'Renamed-Folder'); +}); + +it('validates move folder input', function () { + postJson(action([FolderController::class, 'move'])) + ->assertJsonValidationErrors(['folderId', 'parentId']); +}); + +it('handles folder move conflicts', function () { + $subfolder = VolumeFolderModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'parentId' => $this->folder->id, + 'path' => $this->folder->path.'same-name/', + 'name' => 'same-name', + ]); + + $destination = VolumeFolderModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'parentId' => $this->folder->id, + 'path' => $this->folder->path.'destination/', + 'name' => 'destination', + ]); + + // Create a conflicting folder at the destination + VolumeFolderModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'parentId' => $destination->id, + 'path' => $destination->path.'same-name/', + 'name' => 'same-name', + ]); + + postJson(action([FolderController::class, 'move']), [ + 'folderId' => $subfolder->id, + 'parentId' => $destination->id, + ]) + ->assertOk() + ->assertJsonStructure(['conflict']); +}); diff --git a/tests/Feature/Http/Controllers/Assets/IconControllerTest.php b/tests/Feature/Http/Controllers/Assets/IconControllerTest.php new file mode 100644 index 00000000000..4c1e17ef303 --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/IconControllerTest.php @@ -0,0 +1,34 @@ +cpTrigger = Cms::config()->cpTrigger; +}); + +it('requires authentication', function () { + auth()->logout(); + + get(action(IconController::class, ['extension' => 'jpg'])) + ->assertRedirect(); +}); + +it('returns SVG for known extension', function () { + get(action(IconController::class, ['extension' => 'jpg'])) + ->assertOk() + ->assertHeader('Content-Type', 'image/svg+xml'); +}); + +it('returns SVG for unknown extension', function () { + get(action(IconController::class, ['extension' => 'xyz'])) + ->assertOk() + ->assertHeader('Content-Type', 'image/svg+xml'); +}); diff --git a/tests/Feature/Http/Controllers/Assets/ImageEditorControllerTest.php b/tests/Feature/Http/Controllers/Assets/ImageEditorControllerTest.php new file mode 100644 index 00000000000..71b64c790bc --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/ImageEditorControllerTest.php @@ -0,0 +1,111 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/image-editor-test/test-disk'), + ]); + + $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); +}); + +describe('show', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ImageEditorController::class, 'show']), [ + 'assetId' => 1, + ])->assertUnauthorized(); + }); + + it('validates input', function () { + postJson(action([ImageEditorController::class, 'show'])) + ->assertJsonValidationErrors(['assetId']); + }); + + it('can show the image editor', function () { + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'editor-test.jpg', + 'kind' => 'image', + ]); + + postJson(action([ImageEditorController::class, 'show']), [ + 'assetId' => $asset->id, + ]) + ->assertOk() + ->assertJsonStructure(['html']); + }); +}); + +describe('editImage', function () { + it('requires authentication', function () { + auth()->logout(); + + get(action([ImageEditorController::class, 'editImage'], [ + 'assetId' => 1, + 'size' => 500, + ]))->assertRedirect(); + }); +}); + +describe('save', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ImageEditorController::class, 'save']), [ + 'assetId' => 1, + ])->assertUnauthorized(); + }); + + it('validates input', function () { + postJson(action([ImageEditorController::class, 'save'])) + ->assertJsonValidationErrors(['assetId', 'viewportRotation', 'imageRotation', 'replace', 'cropData']); + }); +}); + +describe('updateFocalPoint', function () { + it('requires authentication', function () { + auth()->logout(); + + postJson(action([ImageEditorController::class, 'updateFocalPoint']), [ + 'assetUid' => 'test-uid', + ])->assertUnauthorized(); + }); + + it('validates input', function () { + postJson(action([ImageEditorController::class, 'updateFocalPoint'])) + ->assertJsonValidationErrors(['assetUid', 'focal', 'focalEnabled']); + }); + + it('can update focal point', function () { + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'focal-test.jpg', + 'kind' => 'image', + ]); + + postJson(action([ImageEditorController::class, 'updateFocalPoint']), [ + 'assetUid' => $asset->uid, + 'focal' => ['x' => 0.5, 'y' => 0.5], + 'focalEnabled' => true, + ])->assertOk(); + }); +}); diff --git a/tests/Feature/Http/Controllers/Assets/IndexControllerTest.php b/tests/Feature/Http/Controllers/Assets/IndexControllerTest.php new file mode 100644 index 00000000000..e63487ac91d --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/IndexControllerTest.php @@ -0,0 +1,46 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/index-controller-test/test-disk'), + ]); +}); + +it('requires authentication', function () { + auth()->logout(); + + $cpTrigger = Cms::config()->cpTrigger; + + get("/{$cpTrigger}/assets") + ->assertRedirect(); +}); + +it('renders the assets index page', function () { + $cpTrigger = Cms::config()->cpTrigger; + + get("/{$cpTrigger}/assets") + ->assertOk(); +}); + +it('renders with a default source', function () { + $volume = Volume::factory()->create([ + 'fs' => 'disk:test-disk', + 'handle' => 'testvolume', + ]); + + $cpTrigger = Cms::config()->cpTrigger; + + get("/{$cpTrigger}/assets", ['defaultSource' => $volume->handle])->assertOk(); +}); diff --git a/tests/Feature/Http/Controllers/Assets/PreviewControllerTest.php b/tests/Feature/Http/Controllers/Assets/PreviewControllerTest.php new file mode 100644 index 00000000000..636ca76e29a --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/PreviewControllerTest.php @@ -0,0 +1,85 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/preview-controller-test/test-disk'), + ]); + + $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); +}); + +it('requires authentication', function () { + auth()->logout(); + + postJson(action([PreviewController::class, 'previewThumb']), [ + 'assetId' => 1, + 'width' => 100, + 'height' => 100, + ])->assertUnauthorized(); +}); + +it('validates preview thumb input', function () { + postJson(action([PreviewController::class, 'previewThumb'])) + ->assertJsonValidationErrors(['assetId', 'width', 'height']); +}); + +it('can preview a thumb', function () { + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'test.jpg', + 'kind' => 'image', + ]); + + postJson(action([PreviewController::class, 'previewThumb']), [ + 'assetId' => $asset->id, + 'width' => 100, + 'height' => 100, + ]) + ->assertOk() + ->assertJsonStructure(['img']); +}); + +it('validates preview file input', function () { + postJson(action([PreviewController::class, 'previewFile'])) + ->assertJsonValidationErrors(['assetId', 'requestId']); +}); + +it('can preview a file', function () { + // Create the file on disk so the preview handler can open it + $diskRoot = storage_path('framework/testing/preview-controller-test/test-disk'); + if (! is_dir($diskRoot)) { + mkdir($diskRoot, 0755, true); + } + file_put_contents($diskRoot.'/test.txt', 'Hello world'); + + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'test.txt', + 'kind' => 'text', + ]); + + postJson(action([PreviewController::class, 'previewFile']), [ + 'assetId' => $asset->id, + 'requestId' => 'req-123', + ]) + ->assertOk() + ->assertJsonStructure(['previewHtml', 'requestId']) + ->assertJsonPath('requestId', 'req-123'); +}); diff --git a/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php b/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php new file mode 100644 index 00000000000..25c3fc15703 --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/TransformControllerTest.php @@ -0,0 +1,89 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/transform-controller-test/test-disk'), + ]); + + $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); +}); + +describe('generate', function () { + it('allows anonymous access', function () { + $asset = AssetModel::factory()->create([ + 'volumeId' => test()->volume->id, + 'folderId' => test()->folder->id, + 'filename' => 'transform-test.jpg', + 'kind' => 'image', + ]); + + // Anonymous access should not return 401/403 + $response = postJson(action([TransformController::class, 'generate']), [ + 'assetId' => $asset->id, + 'handle' => '_100x100_crop_center-center_none', + ]); + + expect($response->status())->not->toBe(401) + ->and($response->status())->not->toBe(403); + }); + + it('returns error for missing asset id', function () { + actingAs(User::findOne()); + + postJson(action([TransformController::class, 'generate']), [ + 'handle' => '_100x100_crop_center-center_none', + ])->assertStatus(400); + }); + + it('returns error for missing handle', function () { + actingAs(User::findOne()); + + $asset = AssetModel::factory()->create([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + ]); + + postJson(action([TransformController::class, 'generate']), [ + 'assetId' => $asset->id, + ])->assertStatus(400); + }); +}); + +describe('generateFallback', function () { + it('allows anonymous access', function () { + $asset = AssetModel::factory()->create([ + 'volumeId' => test()->volume->id, + 'folderId' => test()->folder->id, + 'filename' => 'fallback-test.jpg', + 'kind' => 'image', + ]); + + $transform = Crypt::encrypt($asset->id.',_100x100_crop_center-center_none'); + + // Anonymous access should not return 401/403 + $response = get(action([TransformController::class, 'generateFallback'], ['transform' => $transform])); + + expect($response->status())->not->toBe(401) + ->and($response->status())->not->toBe(403); + }); + + it('returns 400 for invalid encrypted param', function () { + get(action([TransformController::class, 'generateFallback'], ['transform' => 'invalid-data'])) + ->assertStatus(400); + }); +}); diff --git a/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php b/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php new file mode 100644 index 00000000000..e7665ebab73 --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php @@ -0,0 +1,48 @@ +set('filesystems.disks.test-disk', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/upload-controller-test/test-disk'), + ]); + + $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); +}); + +it('requires authentication', function () { + auth()->logout(); + + postJson(action([UploadController::class, 'upload'])) + ->assertUnauthorized(); +}); + +it('requires a file or field for upload', function () { + postJson(action([UploadController::class, 'upload']), [ + 'folderId' => $this->folder->id, + ])->assertStatus(400); +}); + +it('requires authentication for replace file', function () { + auth()->logout(); + + postJson(action([UploadController::class, 'replaceFile'])) + ->assertUnauthorized(); +}); + +it('validates replace file parameters', function () { + postJson(action([UploadController::class, 'replaceFile'])) + ->assertStatus(400); +}); diff --git a/yii2-adapter/composer.json b/yii2-adapter/composer.json index 1d2a50a3c35..647db2d8a47 100644 --- a/yii2-adapter/composer.json +++ b/yii2-adapter/composer.json @@ -94,8 +94,8 @@ }, "scripts": { "copy-icons": "php ./scripts/copyicons.php", - "check-cs": "ecs check --ansi && pint cms --test", - "fix-cs": "npx concurrently -c 'rector' 'ecs check --ansi --fix' --names=rector,ecs", + "check-cs": "ecs check --ansi", + "fix-cs": "ecs check --ansi --fix", "codecept-build": "codecept build", "phpstan": "phpstan --memory-limit=2G", "rector": "rector", diff --git a/yii2-adapter/legacy/assetpreviews/Image.php b/yii2-adapter/legacy/assetpreviews/Image.php index d7e9f4acae1..bdbbcc526a8 100644 --- a/yii2-adapter/legacy/assetpreviews/Image.php +++ b/yii2-adapter/legacy/assetpreviews/Image.php @@ -7,7 +7,6 @@ namespace craft\assetpreviews; -use Craft; use craft\base\AssetPreviewHandler; use craft\helpers\UrlHelper; use yii\base\NotSupportedException; @@ -27,7 +26,7 @@ class Image extends AssetPreviewHandler public function getPreviewHtml(array $variables = []): string { try { - $url = Craft::$app->getAssets()->getImagePreviewUrl($this->asset, 1000, 1000); + $url = app(\CraftCms\Cms\Asset\Assets::class)->getImagePreviewUrl($this->asset, 1000, 1000); } catch (NotSupportedException) { $url = UrlHelper::actionUrl('assets/edit-image', [ 'assetId' => $this->asset->id, diff --git a/yii2-adapter/legacy/base/ApplicationTrait.php b/yii2-adapter/legacy/base/ApplicationTrait.php index 6ee8fe4f021..a3844b18089 100644 --- a/yii2-adapter/legacy/base/ApplicationTrait.php +++ b/yii2-adapter/legacy/base/ApplicationTrait.php @@ -784,6 +784,7 @@ public function getAnnouncements(): Announcements * Returns the assets service. * * @return Assets The assets service + * @deprecated in 6.0.0. Use {@see \CraftCms\Cms\Asset\Assets} or {@see \CraftCms\Cms\Asset\Folders} instead. */ public function getAssets(): Assets { diff --git a/yii2-adapter/legacy/base/AssetPreviewHandler.php b/yii2-adapter/legacy/base/AssetPreviewHandler.php index 7c3c02c28a9..1d96ab6b88a 100644 --- a/yii2-adapter/legacy/base/AssetPreviewHandler.php +++ b/yii2-adapter/legacy/base/AssetPreviewHandler.php @@ -15,7 +15,7 @@ * @author Pixel & Tonic, Inc. * @since 3.4.0 */ -abstract class AssetPreviewHandler extends Component implements AssetPreviewHandlerInterface +abstract class AssetPreviewHandler extends Component implements \CraftCms\Cms\Asset\Contracts\AssetPreviewHandlerInterface { /** * @var Asset diff --git a/yii2-adapter/legacy/base/AssetPreviewHandlerInterface.php b/yii2-adapter/legacy/base/AssetPreviewHandlerInterface.php index 26d2e9c6fd9..0dc68b95400 100644 --- a/yii2-adapter/legacy/base/AssetPreviewHandlerInterface.php +++ b/yii2-adapter/legacy/base/AssetPreviewHandlerInterface.php @@ -9,20 +9,26 @@ use yii\base\NotSupportedException; -/** - * AssetPreviewHandlerInterface defines the common interface to be implemented by classes that provide asset previewing functionality. - * - * @author Pixel & Tonic, Inc. - * @since 3.4.0 - */ -interface AssetPreviewHandlerInterface -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * Returns the asset preview HTML. + * AssetPreviewHandlerInterface defines the common interface to be implemented by classes that provide asset previewing functionality. * - * @param array $variables Additional variables to pass to the template. - * @return string The preview modal HTML - * @throws NotSupportedException if the asset can’t be previewed + * @author Pixel & Tonic, Inc. + * @since 3.4.0 + * @deprecated in 6.0.0. Use {@see \CraftCms\Cms\Asset\Contracts\AssetPreviewHandlerInterface} instead. */ - public function getPreviewHtml(array $variables = []): string; + interface AssetPreviewHandlerInterface + { + /** + * Returns the asset preview HTML. + * + * @param array $variables Additional variables to pass to the template. + * @return string The preview modal HTML + * @throws NotSupportedException if the asset can’t be previewed + */ + public function getPreviewHtml(array $variables = []): string; + } } + +class_alias(\CraftCms\Cms\Asset\Contracts\AssetPreviewHandlerInterface::class, AssetPreviewHandlerInterface::class); diff --git a/yii2-adapter/legacy/config/cproutes/common.php b/yii2-adapter/legacy/config/cproutes/common.php index 6250ba9fd91..0533984e440 100644 --- a/yii2-adapter/legacy/config/cproutes/common.php +++ b/yii2-adapter/legacy/config/cproutes/common.php @@ -4,8 +4,6 @@ return [ 'assets/edit/' => 'elements/edit', - 'assets' => 'assets/index', - 'assets/' => 'assets/index', 'edit/' => 'elements/redirect', 'edit/' => 'elements/redirect', 'revisions/' => 'elements/revisions', diff --git a/yii2-adapter/legacy/controllers/AssetsController.php b/yii2-adapter/legacy/controllers/AssetsController.php index 06fd9455edd..37193e3fef2 100644 --- a/yii2-adapter/legacy/controllers/AssetsController.php +++ b/yii2-adapter/legacy/controllers/AssetsController.php @@ -10,176 +10,41 @@ namespace craft\controllers; use Craft; -use craft\assetpreviews\Image as ImagePreview; -use craft\errors\ElementNotFoundException; -use craft\errors\UploadFailedException; -use craft\helpers\Assets; -use craft\helpers\Db; -use craft\helpers\FileHelper; -use craft\helpers\ImageTransforms; -use craft\helpers\UrlHelper; -use craft\imagetransforms\ImageTransformer; -use craft\models\ImageTransform; use craft\web\Controller; use craft\web\UploadedFile; -use CraftCms\Aliases\Aliases; -use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; -use CraftCms\Cms\Asset\Exceptions\AssetDisallowedExtensionException; -use CraftCms\Cms\Asset\Exceptions\AssetException; -use CraftCms\Cms\Asset\Exceptions\VolumeException; -use CraftCms\Cms\Asset\Volumes; -use CraftCms\Cms\Cms; -use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Deprecator\Exceptions\DeprecationException; -use CraftCms\Cms\Element\Conditions\ElementCondition; use CraftCms\Cms\Element\Element; -use CraftCms\Cms\Field\Assets as AssetsField; -use CraftCms\Cms\Field\Fields; -use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; -use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Deprecator; -use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\Support\Str; -use CraftCms\Cms\Translation\Formatter; -use Illuminate\Contracts\Encryption\DecryptException; -use Illuminate\Filesystem\LocalFilesystemAdapter; -use Illuminate\Support\Facades\Crypt; -use Throwable; -use Twig\Error\LoaderError; -use Twig\Error\RuntimeError; -use Twig\Error\SyntaxError; -use yii\base\Exception; -use yii\base\InvalidConfigException; -use yii\base\InvalidRouteException; -use yii\base\NotSupportedException; -use yii\base\UserException; use yii\web\BadRequestHttpException; -use yii\web\ForbiddenHttpException; -use yii\web\HttpException; -use yii\web\MethodNotAllowedHttpException; -use yii\web\NotFoundHttpException; -use yii\web\RangeNotSatisfiableHttpException; use yii\web\Response; -use yii\web\ServerErrorHttpException; -use ZipArchive; -use function CraftCms\Cms\maxPowerCaptain; use function CraftCms\Cms\t; -use function CraftCms\Cms\template; - -/** @noinspection ClassOverridesFieldOfSuperClassInspection */ /** * The AssetsController class is a controller that handles various actions related to asset tasks, such as uploading * files and creating/deleting/renaming files and folders. - * Note that all actions in the controller except for [[actionGenerateTransform()]] and [[actionGenerateThumb()]] - * require an authenticated Craft session via [[allowAnonymous]]. + * + * Note: Most actions have been ported to Laravel controllers in `CraftCms\Cms\Http\Controllers\Assets\`. + * Only the deprecated {@link actionSaveAsset()} remains here for backwards compatibility. * * @author Pixel & Tonic, Inc. * * @since 3.0.0 + * @deprecated 6.0.0 */ class AssetsController extends Controller { use AssetsControllerTrait; - /** - * {@inheritdoc} - */ - protected array|bool|int $allowAnonymous = ['generate-thumb', 'generate-transform']; - - /** - * Displays the Assets index page. - * - * @return Response - * - * @since 4.4.0 - */ - public function actionIndex(?string $defaultSource = null) - { - $this->requireCpRequest(); - - $variables = []; - - if ($defaultSource) { - $defaultSourcePath = Arr::whereNotEmpty(explode('/', $defaultSource)); - $volumesService = app(Volumes::class); - $volume = $volumesService->getVolumeByHandle(array_shift($defaultSourcePath)); - - if ($volume) { - $assetsService = Craft::$app->getAssets(); - $variables['defaultSource'] = "volume:$volume->uid"; - - if (!empty($defaultSourcePath)) { - $subfolder = $assetsService->findFolder([ - 'volumeId' => $volume->id, - 'path' => sprintf('%s/', implode('/', $defaultSourcePath)), - ]); - if ($subfolder) { - $sourcePath = []; - /** @var VolumeFolder[] $folders */ - $folders = []; - while ($subfolder) { - array_unshift($folders, $subfolder); - $subfolder = $subfolder->getParent(); - } - foreach ($folders as $i => $folder) { - if ($i < count($folders) - 1) { - $folder->setHasChildren(true); - } - $sourcePath[] = $folder->getSourcePathInfo(); - } - $variables['defaultSourcePath'] = $sourcePath; - } - } - } - } - - return $this->rendertemplate('assets/_index', $variables); - } - - /** - * Returns an updated preview image for an asset. - * - * @throws BadRequestHttpException - * @throws NotSupportedException - * - * @since 3.4.0 - */ - public function actionPreviewThumb(): Response - { - $this->requireCpRequest(); - $assetId = $this->request->getRequiredParam('assetId'); - $width = $this->request->getRequiredParam('width'); - $height = $this->request->getRequiredParam('height'); - - $asset = Asset::findOne($assetId); - if ($asset === null) { - throw new BadRequestHttpException("Invalid asset ID: $assetId"); - } - - return $this->asJson([ - 'img' => $asset->getPreviewThumbImg($width, $height), - ]); - } - /** * Saves an asset. * + * @return Response|null + * * @throws BadRequestHttpException - * @throws Exception - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - * @throws Throwable - * @throws DeprecationException - * @throws ElementNotFoundException - * @throws InvalidRouteException * - * @since 3.4.0 - * @deprecated in 4.0.0 + * @deprecated in 4.0.0. Use `assets/upload` for uploads, or save elements directly. */ public function actionSaveAsset(): ?Response { @@ -243,1260 +108,4 @@ public function actionSaveAsset(): ?Response ], ); } - - /** - * Handles a file upload. - * - * @throws BadRequestHttpException - * - * @since 3.4.0 - */ - public function actionUpload(): Response - { - $this->requireAcceptsJson(); - - $elementsService = Craft::$app->getElements(); - $uploadedFile = UploadedFile::getInstanceByName('assets-upload'); - - if (!$uploadedFile) { - throw new BadRequestHttpException('No file was uploaded'); - } - - $folderId = (int) $this->request->getBodyParam('folderId') ?: null; - $fieldId = (int) $this->request->getBodyParam('fieldId') ?: null; - - if (!$folderId && !$fieldId) { - throw new BadRequestHttpException('No target destination provided for uploading'); - } - - $assets = Craft::$app->getAssets(); - - $tempPath = $this->_getUploadedFileTempPath($uploadedFile); - - if (empty($folderId)) { - /** @var AssetsField|null $field */ - $field = app(Fields::class)->getFieldById((int) $fieldId); - - if (!$field instanceof AssetsField) { - throw new BadRequestHttpException('The field provided is not an Assets field'); - } - - if ($elementId = $this->request->getBodyParam('elementId')) { - $siteId = $this->request->getBodyParam('siteId') ?: null; - $element = $elementsService->getElementById($elementId, null, $siteId); - } else { - $element = null; - } - $folderId = $field->resolveDynamicPathToFolderId($element); - - $selectionCondition = $field->getSelectionCondition(); - if ($selectionCondition instanceof ElementCondition) { - $selectionCondition->referenceElement = $element; - } - } else { - $selectionCondition = null; - } - - if (empty($folderId)) { - throw new BadRequestHttpException('The target destination provided for uploading is not valid'); - } - - $folder = $assets->findFolder(['id' => $folderId]); - - if (!$folder) { - throw new BadRequestHttpException('The target folder provided for uploading is not valid'); - } - - // Check the permissions to upload in the resolved folder. - $this->requireVolumePermissionByFolder('saveAssets', $folder); - - $filename = Assets::prepareAssetName($uploadedFile->name); - - if ($selectionCondition) { - $tempFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); - if ($folder->id !== $tempFolder->id) { - // upload to the user's temp folder initially, with a temp name - $originalFolder = $folder; - $originalFilename = $filename; - $folder = $tempFolder; - $filename = uniqid('asset', true) . '.' . pathinfo($filename, PATHINFO_EXTENSION); - } - } - - $asset = new Asset(); - $asset->tempFilePath = $tempPath; - $asset->setFilename($filename); - $asset->setMimeType(FileHelper::getMimeType($tempPath, checkExtension: false) ?? $uploadedFile->type); - $asset->newFolderId = $folder->id; - $asset->setVolumeId($folder->volumeId); - $asset->uploaderId = Craft::$app->getUser()->getId(); - $asset->avoidFilenameConflicts = true; - - if (isset($originalFilename)) { - $asset->title = Assets::filename2Title(pathinfo($originalFilename, PATHINFO_FILENAME)); - } - - $asset->setScenario(Asset::SCENARIO_CREATE); - $result = $elementsService->saveElement($asset); - - // In case of error, let user know about it. - if (!$result) { - return $this->asModelFailure($asset); - } - - if ($selectionCondition) { - if (!$selectionCondition->matchElement($asset)) { - // delete and reject it - $elementsService->deleteElement($asset, true); - - return $this->asFailure(t('{filename} isn’t selectable for this field.', [ - 'filename' => $uploadedFile->name, - ])); - } - - if (isset($originalFilename, $originalFolder)) { - // move it into the original target destination - $asset->newFilename = $originalFilename; - $asset->newFolderId = $originalFolder->id; - $asset->setScenario(Asset::SCENARIO_MOVE); - - if (!$elementsService->saveElement($asset)) { - return $this->asModelFailure($asset); - } - } - } - - // try to get uploaded asset's URL - $url = null; - try { - $url = $asset->getUrl(); - } catch (Throwable) { - // do nothing - } - - if ($asset->conflictingFilename !== null) { - $conflictingAsset = Asset::findOne(['folderId' => $folder->id, 'filename' => $asset->conflictingFilename]); - - return $this->asJson([ - 'conflict' => t('A file with the name “{filename}” already exists.', ['filename' => $asset->conflictingFilename]), - 'assetId' => $asset->id, - 'filename' => $asset->conflictingFilename, - 'conflictingAssetId' => $conflictingAsset->id ?? null, - 'suggestedFilename' => $asset->suggestedFilename, - 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->getFs()->hasUrls) ? $conflictingAsset->getUrl() : null, - 'url' => $url, - ]); - } - - return $this->asSuccess(data: [ - 'filename' => $asset->getFilename(), - 'assetId' => $asset->id, - 'url' => $url, - ]); - } - - /** - * Replaces a file. - * - * @throws BadRequestHttpException if incorrect combination of parameters passed. - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws NotFoundHttpException if the asset can’t be found - * @throws VolumeException - */ - public function actionReplaceFile(): Response - { - $this->requireAcceptsJson(); - $assetId = $this->request->getBodyParam('assetId'); - - $sourceAssetId = $this->request->getBodyParam('sourceAssetId'); - $targetFilename = $this->request->getBodyParam('targetFilename'); - - if ( - $targetFilename && - (str_contains($targetFilename, '/') || str_contains($targetFilename, '\\')) - ) { - throw new BadRequestHttpException('Invalid filename: $targetFilename'); - } - - $uploadedFile = UploadedFile::getInstanceByName('replaceFile'); - - $assets = Craft::$app->getAssets(); - - // Must have at least one existing asset (source or target). - // Must have either target asset or target filename. - // Must have either uploaded file or source asset. - if ((empty($assetId) && empty($sourceAssetId)) || - (empty($assetId) && empty($targetFilename)) || - ($uploadedFile === null && empty($sourceAssetId)) - ) { - throw new BadRequestHttpException('Incorrect combination of parameters.'); - } - - $sourceAsset = null; - $assetToReplace = null; - - if ($assetId && !$assetToReplace = $assets->getAssetById($assetId)) { - throw new NotFoundHttpException('Asset not found.'); - } - - if ($sourceAssetId && !$sourceAsset = $assets->getAssetById($sourceAssetId)) { - throw new NotFoundHttpException('Asset not found.'); - } - - $this->requireVolumePermissionByAsset('replaceFiles', $assetToReplace ?: $sourceAsset); - $this->requirePeerVolumePermissionByAsset('replacePeerFiles', $assetToReplace ?: $sourceAsset); - - // Handle the Element Action - if ($assetToReplace !== null && $uploadedFile) { - $tempPath = $this->_getUploadedFileTempPath($uploadedFile); - $filename = Assets::prepareAssetName($uploadedFile->name); - $assets->replaceAssetFile($assetToReplace, $tempPath, $filename, $uploadedFile->type); - } elseif ($sourceAsset !== null) { - // Or replace using an existing Asset - - // See if we can find an Asset to replace. - if ($assetToReplace === null) { - // Make sure the extension didn't change - if (pathinfo($targetFilename, PATHINFO_EXTENSION) !== $sourceAsset->getExtension()) { - throw new Exception($targetFilename . ' doesn\'t have the original file extension.'); - } - - /** @var Asset|null $assetToReplace */ - $assetToReplace = Asset::find() - ->select(['elements.id']) - ->folderId($sourceAsset->folderId) - ->filename(Db::escapeParam($targetFilename)) - ->one(); - } - - // If we have an actual asset for which to replace the file, just do it. - if (!empty($assetToReplace)) { - $tempPath = $sourceAsset->getCopyOfFile(); - $assets->replaceAssetFile($assetToReplace, $tempPath, $assetToReplace->getFilename(), $sourceAsset->getMimeType()); - Craft::$app->getElements()->deleteElement($sourceAsset); - } else { - // If all we have is the filename, then make sure that the destination is empty and go for it. - // This can happen when you replace a file via front-end with a form that contains fields named: - // - sourceAssetId - ID of the asset that we want to replace the file for, - // - targetFilename - filename of the file we're replacing with, - // - replaceFile - the file we're replacing with - $volume = $sourceAsset->getVolume(); - $volume->sourceDisk()->delete(rtrim($sourceAsset->folderPath, '/') . '/' . $targetFilename); - $sourceAsset->newFilename = $targetFilename; - // Don't validate required custom fields - Craft::$app->getElements()->saveElement($sourceAsset); - $assetId = $sourceAsset->id; - } - } - - $resultingAsset = $assetToReplace ?: $sourceAsset; - - return $this->asSuccess(data: [ - 'assetId' => $assetId, - 'filename' => $resultingAsset->getFilename(), - 'formattedSize' => $resultingAsset->getFormattedSize(0), - 'formattedSizeInBytes' => $resultingAsset->getFormattedSizeInBytes(false), - 'formattedDateUpdated' => I18N::getFormatter()->asDatetime($resultingAsset->dateUpdated, Formatter::FORMAT_WIDTH_SHORT), - 'dimensions' => $resultingAsset->getDimensions(), - 'updatedTimestamp' => $resultingAsset->dateUpdated->getTimestamp(), - 'resultingUrl' => $resultingAsset->getUrl(), - ]); - } - - /** - * Creates a folder. - * - * @throws BadRequestHttpException if the parent folder cannot be found - * @throws InvalidConfigException - */ - public function actionCreateFolder(): Response - { - $this->requireAcceptsJson(); - $parentId = $this->request->getRequiredBodyParam('parentId'); - $folderName = $this->request->getRequiredBodyParam('folderName'); - $folderName = Assets::prepareAssetName($folderName, false); - - $assets = Craft::$app->getAssets(); - $parentFolder = $assets->findFolder(['id' => $parentId]); - - if (!$parentFolder) { - throw new BadRequestHttpException('The parent folder cannot be found'); - } - - try { - // Check if it's possible to create subfolders in the target volume. - $this->requireVolumePermissionByFolder('createFolders', $parentFolder); - - $folderModel = new VolumeFolder(); - $folderModel->name = $folderName; - $folderModel->parentId = $parentId; - $folderModel->volumeId = $parentFolder->volumeId; - $folderModel->path = $parentFolder->path . $folderName . '/'; - - $assets->createFolder($folderModel); - - return $this->asSuccess(data: [ - 'folderName' => $folderModel->name, - 'folderUid' => $folderModel->uid, - 'folderId' => $folderModel->id, - ]); - } catch (UserException $exception) { - return $this->asFailure($exception->getMessage()); - } - } - - /** - * Delete a folder. - * - * @throws BadRequestHttpException if the folder cannot be found - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws FilesystemException - * @throws Throwable - */ - public function actionDeleteFolder(): Response - { - $this->requireAcceptsJson(); - $folderId = $this->request->getRequiredBodyParam('folderId'); - - $assets = Craft::$app->getAssets(); - $folder = $assets->getFolderById($folderId); - - if (!$folder) { - throw new BadRequestHttpException('The folder cannot be found'); - } - - // Check if it's possible to delete objects in the target volume. - $this->requireVolumePermissionByFolder('deleteAssets', $folder); - $assets->deleteFoldersByIds($folderId); - - return $this->asSuccess(); - } - - /** - * Deletes an asset. - * - * @throws BadRequestHttpException if the folder cannot be found - * @throws ForbiddenHttpException - * @throws Throwable - */ - public function actionDeleteAsset(): ?Response - { - $this->requirePostRequest(); - - $assetId = $this->request->getBodyParam('sourceId') ?? $this->request->getRequiredBodyParam('assetId'); - $asset = Craft::$app->getAssets()->getAssetById($assetId); - - if (!$asset) { - throw new BadRequestHttpException("Invalid asset ID: $assetId"); - } - - // Check if it's possible to delete objects in the target volume. - $this->requireVolumePermissionByAsset('deleteAssets', $asset); - $this->requirePeerVolumePermissionByAsset('deletePeerAssets', $asset); - - $success = Craft::$app->getElements()->deleteElement($asset); - - if (!$success) { - return $this->asModelFailure( - $asset, - t('Couldn’t delete {type}.', [ - 'type' => Asset::lowerDisplayName(), - ]), - 'asset' - ); - } - - return $this->asModelSuccess( - $asset, - t('{type} deleted.', [ - 'type' => Asset::displayName(), - ]), - 'asset', - ); - } - - /** - * Renames a folder. - * - * @throws BadRequestHttpException if the folder cannot be found - * @throws ForbiddenHttpException - * @throws InvalidConfigException|VolumeException - */ - public function actionRenameFolder(): Response - { - $this->requireAcceptsJson(); - - $assets = Craft::$app->getAssets(); - $folderId = $this->request->getRequiredBodyParam('folderId'); - $newName = $this->request->getRequiredBodyParam('newName'); - $folder = $assets->getFolderById($folderId); - - if (!$folder) { - throw new BadRequestHttpException('The folder cannot be found'); - } - - // Check if it's possible to delete objects and create folders in the target volume. - $this->requireVolumePermissionByFolder('deleteAssets', $folder); - $this->requireVolumePermissionByFolder('createFolders', $folder); - - $newName = Craft::$app->getAssets()->renameFolderById($folderId, $newName); - - return $this->asSuccess(data: ['newName' => $newName]); - } - - /** - * Move one or more assets. - * - * @throws BadRequestHttpException if the asset or the target folder cannot be found - * @throws Exception - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - * @throws Throwable - * @throws ElementNotFoundException - */ - public function actionMoveAsset(): Response - { - $this->requireAcceptsJson(); - - $assetsService = Craft::$app->getAssets(); - - // Get the asset - $assetId = $this->request->getRequiredBodyParam('assetId'); - $asset = $assetsService->getAssetById($assetId); - - if ($asset === null) { - throw new BadRequestHttpException('The Asset cannot be found'); - } - - // Get the target folder - $folderId = $this->request->getBodyParam('folderId', $asset->folderId); - $folder = $assetsService->getFolderById($folderId); - - if ($folder === null) { - throw new BadRequestHttpException('The folder cannot be found'); - } - - // Get the target filename - $filename = $this->request->getBodyParam('filename') ?? $asset->getFilename(); - - // Check if it's possible to delete objects in the source volume and save assets in the target volume. - $this->requireVolumePermissionByFolder('saveAssets', $folder); - $this->requireVolumePermissionByAsset('deleteAssets', $asset); - $this->requirePeerVolumePermissionByAsset('savePeerAssets', $asset); - $this->requirePeerVolumePermissionByAsset('deletePeerAssets', $asset); - - if ($this->request->getBodyParam('force')) { - // Check for a conflicting asset - /** @var Asset|null $conflictingAsset */ - $conflictingAsset = Asset::find() - ->select(['elements.id']) - ->folderId($folderId) - ->filename(Db::escapeParam($asset->getFilename())) - ->one(); - - // If there's a conflicting asset, then merge and replace the file. - if ($conflictingAsset) { - Craft::$app->getElements()->mergeElementsByIds($conflictingAsset->id, $asset->id); - } else { - $volume = $folder->getVolume(); - $volume->sourceDisk()->delete(rtrim($folder->path, '/') . '/' . $asset->getFilename()); - } - } - - $result = $assetsService->moveAsset($asset, $folder, $filename); - - if (!$result) { - // Get the corrected filename - [, $filename] = Assets::parseFileLocation($asset->newLocation); - - return $this->asJson([ - 'conflict' => $asset->errors()->first('newLocation'), - 'suggestedFilename' => $asset->suggestedFilename, - 'filename' => $filename, - 'assetId' => $asset->id, - ]); - } - - return $this->asSuccess(); - } - - /** - * Moves a folder. - * - * @throws BadRequestHttpException if the folder to move, or the destination parent folder, cannot be found - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - * @throws Throwable - */ - public function actionMoveFolder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $folderBeingMovedId = $this->request->getRequiredBodyParam('folderId'); - $newParentFolderId = $this->request->getRequiredBodyParam('parentId'); - $force = $this->request->getBodyParam('force', false); - $merge = !$force ? $this->request->getBodyParam('merge', false) : false; - - $assets = Craft::$app->getAssets(); - $folderToMove = $assets->getFolderById($folderBeingMovedId); - $destinationFolder = $assets->getFolderById($newParentFolderId); - - if ($folderToMove === null) { - throw new BadRequestHttpException('The folder you are trying to move does not exist'); - } - - if ($destinationFolder === null) { - throw new BadRequestHttpException('The destination folder does not exist'); - } - - // Check if it's possible to delete objects in the source volume, create folders - // in the target volume, and save assets in the target volume. - $this->requireVolumePermissionByFolder('deleteAssets', $folderToMove); - $this->requireVolumePermissionByFolder('createFolders', $destinationFolder); - $this->requireVolumePermissionByFolder('saveAssets', $destinationFolder); - - $targetVolume = $destinationFolder->getVolume(); - - $existingFolder = $assets->findFolder([ - 'parentId' => $newParentFolderId, - 'name' => $folderToMove->name, - ]); - - if (!$existingFolder) { - $existingFolder = $targetVolume->sourceDisk()->directoryExists(trim(rtrim($destinationFolder->path, '/') . '/' . $folderToMove->name, '/')); - } - - // If there's a conflict and `force`/`merge` flags weren't passed in, then STOP RIGHT THERE! - if ($existingFolder && !$force && !$merge) { - // Throw a prompt - return $this->asJson([ - 'conflict' => t('Folder “{folder}” already exists at target location', ['folder' => $folderToMove->name]), - 'folderId' => $folderBeingMovedId, - 'parentId' => $newParentFolderId, - ]); - } - - $sourceTree = $assets->getAllDescendantFolders($folderToMove); - - if (!$existingFolder) { - // No conflicts, mirror the existing structure - $folderIdChanges = Assets::mirrorFolderStructure($folderToMove, $destinationFolder); - - // Get the file transfer list. - $allSourceFolderIds = array_keys($sourceTree); - $allSourceFolderIds[] = $folderBeingMovedId; - /** @var Asset[] $foundAssets */ - $foundAssets = Asset::find() - ->folderId($allSourceFolderIds) - ->all(); - $fileTransferList = Assets::fileTransferList($foundAssets, $folderIdChanges); - } else { - $targetTreeMap = []; - - // If an indexed folder is conflicting - if ($existingFolder instanceof VolumeFolder) { - // Delete if using force - if ($force) { - try { - $assets->deleteFoldersByIds($existingFolder->id); - } catch (VolumeException $exception) { - Craft::$app->getErrorHandler()->logException($exception); - - return $this->asFailure(t('Directories cannot be deleted while moving assets.')); - } - } else { - // Or build a map of existing folders for file move - $targetTree = $assets->getAllDescendantFolders($existingFolder); - $targetPrefixLength = strlen($destinationFolder->path); - - foreach ($targetTree as $existingFolder) { - $targetTreeMap[substr($existingFolder->path, - $targetPrefixLength)] = $existingFolder->id; - } - } - } elseif ($force) { - // An un-indexed folder is conflicting. If we're forcing things, just remove it. - $targetVolume->sourceDisk()->deleteDirectory(trim(rtrim($destinationFolder->path, '/') . '/' . $folderToMove->name, '/')); - } - - // Mirror the structure, passing along the existing folder map - $folderIdChanges = Assets::mirrorFolderStructure($folderToMove, $destinationFolder, $targetTreeMap); - - // Get file transfer list for the progress bar - $allSourceFolderIds = array_keys($sourceTree); - $allSourceFolderIds[] = $folderBeingMovedId; - /** @var Asset[] $foundAssets */ - $foundAssets = Asset::find() - ->folderId($allSourceFolderIds) - ->all(); - $fileTransferList = Assets::fileTransferList($foundAssets, $folderIdChanges); - } - - $newFolderId = $folderIdChanges[$folderBeingMovedId] ?? null; - $newFolder = $assets->getFolderById($newFolderId); - - return $this->asSuccess(data: [ - 'transferList' => $fileTransferList, - 'newFolderUid' => $newFolder->uid, - 'newFolderId' => $newFolderId, - ]); - } - - /** - * Returns the Image Editor template. - * - * @throws BadRequestHttpException if the asset is missing. - * @throws Exception - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - */ - public function actionImageEditor(): Response - { - $assetId = $this->request->getRequiredBodyParam('assetId'); - $asset = Craft::$app->getAssets()->getAssetById($assetId); - - if (!$asset) { - throw new BadRequestHttpException(t('The asset you’re trying to edit does not exist.')); - } - - $focal = $asset->getHasFocalPoint() ? $asset->getFocalPoint() : null; - - $html = template('_special/image_editor'); - - return $this->asJson(['html' => $html, 'focalPoint' => $focal]); - } - - /** - * Returns the image being edited. - * - * @throws BadRequestHttpException - * @throws Exception - */ - public function actionEditImage(): Response - { - $assetId = (int) $this->request->getRequiredQueryParam('assetId'); - $size = (int) $this->request->getRequiredQueryParam('size'); - - $asset = Asset::findOne($assetId); - if (!$asset) { - throw new BadRequestHttpException('The Asset cannot be found'); - } - - try { - $url = Craft::$app->getAssets()->getImagePreviewUrl($asset, $size, $size); - - return $this->response->redirect($url); - } catch (NotSupportedException) { - // just output the file contents - $path = ImageTransforms::getLocalImageSource($asset); - - return $this->response->sendFile($path, $asset->getFilename()); - } - } - - /** - * Saves an image according to the posted parameters. - * - * @throws BadRequestHttpException if some parameters are missing. - * @throws Throwable if something went wrong saving the asset. - */ - public function actionSaveImage(): Response - { - $this->requireAcceptsJson(); - $assets = Craft::$app->getAssets(); - - $assetId = $this->request->getRequiredBodyParam('assetId'); - $viewportRotation = (int) $this->request->getRequiredBodyParam('viewportRotation'); - $imageRotation = (float) $this->request->getRequiredBodyParam('imageRotation'); - $replace = $this->request->getRequiredBodyParam('replace'); - $cropData = $this->request->getRequiredBodyParam('cropData'); - $focalPoint = $this->request->getBodyParam('focalPoint'); - $imageDimensions = $this->request->getBodyParam('imageDimensions'); - $flipData = $this->request->getBodyParam('flipData'); - $zoom = (float) $this->request->getBodyParam('zoom', 1); - - // avoid a potential division by zero error (somehow) - // see https://github.com/craftcms/cms/issues/17019 - if (!$imageDimensions['width'] || !$imageDimensions['height']) { - throw new BadRequestHttpException('Invalid imageDimensions param'); - } - - $asset = $assets->getAssetById($assetId); - - if ($asset === null) { - throw new BadRequestHttpException('The Asset cannot be found'); - } - - $folder = $asset->getFolder(); - - // Do what you want with your own photo. - if ($asset->id != static::currentUser()->photoId) { - $this->requireVolumePermissionByAsset('editImages', $asset); - $this->requirePeerVolumePermissionByAsset('editPeerImages', $asset); - } - - // Verify parameter adequacy - if (!in_array($viewportRotation, [0, 90, 180, 270], false)) { - throw new BadRequestHttpException('Viewport rotation must be 0, 90, 180 or 270 degrees'); - } - - if ( - is_array($cropData) && - array_diff(['offsetX', 'offsetY', 'height', 'width'], array_keys($cropData)) - ) { - throw new BadRequestHttpException('Invalid cropping parameters passed'); - } - - // TODO Fire an event for any other image editing takers. - $transformer = new ImageTransformer(); - - $originalImageWidth = $asset->width; - $originalImageHeight = $asset->height; - - $transformer->startImageEditing($asset); - - $imageCropped = ($cropData['width'] !== $imageDimensions['width'] || $cropData['height'] !== $imageDimensions['height']); - $imageRotated = $viewportRotation !== 0 || $imageRotation !== 0.0; - $imageFlipped = !empty($flipData['x']) || !empty($flipData['y']); - $imageChanged = $imageCropped || $imageRotated || $imageFlipped; - - if ($imageFlipped) { - $transformer->flipImage(!empty($flipData['x']), !empty($flipData['y'])); - } - - $generalConfig = Cms::config(); - $upscale = $generalConfig->upscaleImages; - $generalConfig->upscaleImages = true; - - if ($zoom !== 1.0) { - $transformer->scaleImage((int) ($originalImageWidth * $zoom), (int) ($originalImageHeight * $zoom)); - } - - $generalConfig->upscaleImages = $upscale; - - if ($imageRotated) { - $transformer->rotateImage($imageRotation + $viewportRotation); - } - - $imageCenterX = $transformer->getEditedImageWidth() / 2; - $imageCenterY = $transformer->getEditedImageHeight() / 2; - - $adjustmentRatio = min($originalImageWidth / $imageDimensions['width'], $originalImageHeight / $imageDimensions['height']); - $width = $cropData['width'] * $zoom * $adjustmentRatio; - $height = $cropData['height'] * $zoom * $adjustmentRatio; - $x = $imageCenterX + ($cropData['offsetX'] * $zoom * $adjustmentRatio) - $width / 2; - $y = $imageCenterY + ($cropData['offsetY'] * $zoom * $adjustmentRatio) - $height / 2; - - $focal = null; - - if ($focalPoint) { - $adjustmentRatio = min($originalImageWidth / $focalPoint['imageDimensions']['width'], $originalImageHeight / $focalPoint['imageDimensions']['height']); - $fx = $imageCenterX + ($focalPoint['offsetX'] * $zoom * $adjustmentRatio) - $x; - $fy = $imageCenterY + ($focalPoint['offsetY'] * $zoom * $adjustmentRatio) - $y; - - $focal = [ - 'x' => $fx / $width, - 'y' => $fy / $height, - ]; - } - - if ($imageCropped) { - $transformer->crop((int) $x, (int) $y, (int) $width, (int) $height); - } - - if ($imageChanged) { - $finalImage = $transformer->finishImageEditing(); - } else { - $finalImage = $transformer->cancelImageEditing(); - } - - $output = []; - - if ($replace) { - $oldFocal = $asset->getHasFocalPoint() ? $asset->getFocalPoint() : null; - $focalChanged = $focal !== $oldFocal; - $asset->setFocalPoint($focal); - - if ($focalChanged) { - $transforms = Craft::$app->getImageTransforms(); - $transforms->deleteCreatedTransformsForAsset($asset); - } - - // Only replace file if it changed, otherwise just save changed focal points - if ($imageChanged) { - $assets->replaceAssetFile($asset, $finalImage, $asset->getFilename(), $asset->getMimeType()); - } elseif ($focalChanged) { - Craft::$app->getElements()->saveElement($asset); - } - } else { - $newAsset = new Asset(); - $newAsset->avoidFilenameConflicts = true; - $newAsset->setScenario(Asset::SCENARIO_CREATE); - - $newAsset->tempFilePath = $finalImage; - $newAsset->setFilename($asset->getFilename()); - $newAsset->newFolderId = $folder->id; - $newAsset->setVolumeId($folder->volumeId); - $newAsset->setFocalPoint($focal); - - // Don't validate required custom fields - Craft::$app->getElements()->saveElement($newAsset); - - $output['newAssetId'] = $newAsset->id; - } - - return $this->asSuccess(data: $output); - } - - /** - * Returns a file’s contents. - * - * @throws AssetException - * @throws BadRequestHttpException if the file to download cannot be found. - * @throws Exception - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - * @throws RangeNotSatisfiableHttpException - */ - public function actionDownloadAsset(): Response - { - $this->requirePostRequest(); - - $assetIds = $this->request->getRequiredBodyParam('assetId'); - /** @var Asset[] $assets */ - $assets = Asset::find() - ->id($assetIds) - ->all(); - - if (empty($assets)) { - throw new BadRequestHttpException(t('The asset you’re trying to download does not exist.')); - } - - foreach ($assets as $asset) { - $this->requireVolumePermissionByAsset('viewAssets', $asset); - $this->requirePeerVolumePermissionByAsset('viewPeerAssets', $asset); - } - - // If only one asset was selected, send it back unzipped - if (count($assets) === 1) { - $asset = reset($assets); - - return $this->response - ->sendStreamAsFile($asset->getStream(), $asset->getFilename(), [ - 'fileSize' => $asset->size, - 'mimeType' => $asset->getMimeType(), - ]); - } - - // Otherwise create a zip of all the selected assets - $zipPath = Craft::$app->getPath()->getTempPath() . '/' . Str::uuid()->toString() . '.zip'; - $zip = new ZipArchive(); - - if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { - throw new Exception('Cannot create zip at ' . $zipPath); - } - - maxPowerCaptain(); - - foreach ($assets as $asset) { - $path = $asset->getVolume()->name . '/' . $asset->getPath(); - $zip->addFromString($path, $asset->getContents()); - } - - $zip->close(); - - return $this->response - ->sendFile($zipPath, 'assets.zip'); - } - - /** - * Returns a file icon with an extension. - * - * @param string $extension The asset’s UID - * - * @since 4.0.0 - */ - public function actionIcon(string $extension): Response - { - $path = Assets::iconPath($extension); - - return $this->response - ->setCacheHeaders() - ->sendFile($path, "$extension.svg", [ - 'inline' => true, - ]); - } - - /** - * Generates a transform. - * - * @throws BadRequestHttpException - * @throws ServerErrorHttpException - */ - public function actionGenerateTransform(?int $transformId = null): Response - { - // If a transform ID was not passed in, see if a file ID and handle were. - if ($transformId) { - $transformer = Craft::createObject(ImageTransformer::class); - $transformIndexModel = $transformer->getTransformIndexModelById($transformId); - if (!$transformIndexModel) { - throw new BadRequestHttpException("Invalid transform ID: $transformId"); - } - $assetId = $transformIndexModel->assetId; - try { - $transform = $transformIndexModel->getTransform(); - } catch (Throwable $e) { - throw new ServerErrorHttpException('Image transform cannot be created.', previous: $e); - } - } else { - $assetId = $this->request->getRequiredBodyParam('assetId'); - $handle = $this->request->getRequiredBodyParam('handle'); - if (!is_string($handle)) { - throw new BadRequestHttpException('Invalid transform handle.'); - } - try { - $transform = ImageTransforms::normalizeTransform($handle); - } catch (Throwable $e) { - throw new ServerErrorHttpException('Image transform cannot be created.', previous: $e); - } - if (!$transform) { - throw new BadRequestHttpException("Invalid transform handle: $handle"); - } - $transformer = $transform->getImageTransformer(); - } - - $asset = Asset::findOne(['id' => $assetId]); - - if (!$asset) { - throw new BadRequestHttpException("Invalid asset ID: $assetId"); - } - - $url = $transformer->getTransformUrl($asset, $transform, true); - - if ($this->request->getAcceptsJson()) { - return $this->asJson(['url' => $url]); - } - - return $this->redirect($url); - } - - /** - * Returns file preview info for an asset. - * - * @throws BadRequestHttpException if not a valid request - */ - public function actionPreviewFile(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $assetId = $this->request->getRequiredParam('assetId'); - $requestId = $this->request->getRequiredParam('requestId'); - - /** @var Asset|null $asset */ - $asset = Asset::find()->id($assetId)->one(); - - if (!$asset) { - return $this->asFailure(t('Asset not found with that id')); - } - - $previewHtml = null; - - $previewHandler = Craft::$app->getAssets()->getAssetPreviewHandler($asset); - $variables = []; - - if ($previewHandler instanceof ImagePreview) { - if ($asset->id != static::currentUser()->photoId) { - $variables['editFocal'] = true; - - try { - $this->requireVolumePermissionByAsset('editImages', $asset); - $this->requirePeerVolumePermissionByAsset('editPeerImages', $asset); - } catch (ForbiddenHttpException) { - $variables['editFocal'] = false; - } - } - } - - if ($previewHandler) { - try { - $previewHtml = $previewHandler->getPreviewHtml($variables); - } catch (NotSupportedException) { - // No big deal - } - } - - $view = $this->getView(); - - return $this->asSuccess(data: [ - 'previewHtml' => $previewHtml, - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), - 'requestId' => $requestId, - ]); - } - - /** - * Update an asset's focal point position. - * - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - */ - public function actionUpdateFocalPosition(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $assetUid = Craft::$app->getRequest()->getRequiredBodyParam('assetUid'); - $focalData = Craft::$app->getRequest()->getRequiredBodyParam('focal'); - $focalEnabled = Craft::$app->getRequest()->getRequiredBodyParam('focalEnabled'); - - // if focal point is disabled, set focal data to null (can't pass null to $focalData as it's a required param) - if ($focalEnabled === false) { - $focalData = null; - } - - /** @var Asset|null $asset */ - $asset = Asset::find()->uid($assetUid)->one(); - - if (!$asset) { - throw new BadRequestHttpException("Invalid asset UID: $assetUid"); - } - - $this->requireVolumePermissionByAsset('editImages', $asset); - $this->requirePeerVolumePermissionByAsset('editPeerImages', $asset); - - $asset->setFocalPoint($focalData); - Craft::$app->getElements()->saveElement($asset); - Craft::$app->getImageTransforms()->deleteCreatedTransformsForAsset($asset); - - return $this->asSuccess(); - } - - /** - * Sends a broken image response based on a given exception. - * - * @param Throwable|null $e The exception that was thrown - * - * @since 3.4.8 - */ - protected function asBrokenImage(?Throwable $e = null): Response - { - $statusCode = $e instanceof HttpException && $e->statusCode ? $e->statusCode : 500; - - return $this->response - ->sendFile(Aliases::get('@appicons/broken-image.svg'), 'nope.svg', [ - 'mimeType' => 'image/svg+xml', - 'inline' => true, - ]) - ->setStatusCode($statusCode); - } - - /** - * @throws UploadFailedException - */ - private function _getUploadedFileTempPath(UploadedFile $uploadedFile): string - { - if ($uploadedFile->getHasError()) { - throw new UploadFailedException($uploadedFile->error); - } - - // Make sure the file extension is allowed - $allowedExtensions = Craft::$app->getConfig()->getGeneral()->allowedFileExtensions; - $extension = strtolower(pathinfo($uploadedFile->name, PATHINFO_EXTENSION)); - - if (is_array($allowedExtensions) && !in_array($extension, $allowedExtensions, true)) { - throw new AssetDisallowedExtensionException(t('“{extension}” is not an allowed file extension.', [ - 'extension' => $extension, - ])); - } - - // Move the uploaded file to the temp folder - $tempPath = $uploadedFile->saveAsTempFile(); - - if ($tempPath === false) { - throw new UploadFailedException(UPLOAD_ERR_CANT_WRITE); - } - - return $tempPath; - } - - /** - * Generates a fallback transform. - * - * @since 4.4.0 - */ - public function actionGenerateFallbackTransform(string $transform): Response - { - try { - $transform = Crypt::decrypt($transform); - } catch (DecryptException) { - throw new BadRequestHttpException('Request contained an invalid transform param.'); - } - - [$assetId, $transformString] = explode(',', $transform, 2); - - /** @var Asset|null $asset */ - $asset = Asset::find()->id($assetId)->one(); - if (!$asset) { - throw new BadRequestHttpException("Invalid asset ID: $assetId"); - } - - $this->response->setCacheHeaders(); - - // If we're returning the original asset, and it's in a local FS, just read the file out directly - $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() - ); - - return $this->response->sendFile($path, $asset->getFilename(), [ - 'inline' => true, - ]); - } - } - - if ($useOriginal) { - $ext = $asset->getExtension(); - } else { - $transform = Craft::createObject([ - 'class' => ImageTransform::class, - ...ImageTransforms::parseTransformString($transformString), - ]); - - $ext = $transform->format ?: ImageTransforms::detectTransformFormat($asset); - } - - $filename = sprintf('%s.%s', $asset->id, $ext); - $path = implode(DIRECTORY_SEPARATOR, [ - Craft::$app->getPath()->getImageTransformsPath(), - $transformString, - $filename, - ]); - - if (!file_exists($path) || filemtime($path) < ($asset->dateModified?->getTimestamp() ?? 0)) { - if ($useOriginal) { - $tempPath = $asset->getCopyOfFile(); - } else { - $tempPath = ImageTransforms::generateTransform($asset, $transform); - } - - FileHelper::createDirectory(dirname($path)); - rename($tempPath, $path); - } - - $responseFilename = sprintf('%s.%s', $asset->getFilename(false), $ext); - - return $this->response - ->setCacheHeaders() - ->sendFile($path, $responseFilename, [ - 'inline' => true, - ]); - } - - /** - * Show in folder action. - * Find asset by id and Return source path info for each folder up until the one the asset is in. - * - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws MethodNotAllowedHttpException - */ - public function actionShowInFolder(): Response - { - $this->requireCpRequest(); - - $assetId = Craft::$app->getRequest()->getRequiredParam('assetId'); - - $asset = Asset::findOne($assetId); - if ($asset === null) { - throw new BadRequestHttpException("Invalid asset ID: $assetId"); - } - - // get the folder for selected asset - $folder = $asset->getFolder(); - $sourcePath[] = $folder->getSourcePathInfo(); - - // for a JSON response (e.g. via element actions) - if ($this->request->getAcceptsJson()) { - // get all the way up to the root folder, cause we need source path info for each step - while (($parent = $folder->getParent()) !== null) { - $sourcePath[] = $parent->getSourcePathInfo(); - $folder = $parent; - } - - $data = [ - 'filename' => $asset->filename, - 'sourcePath' => array_reverse($sourcePath), - ]; - - return $this->asJson($data); - } - - // for a redirect response (e.g. element action menu items) - $uri = Str::start(UrlHelper::prependCpTrigger($sourcePath[0]['uri']), '/'); - $url = UrlHelper::urlWithParams($uri, [ - 'search' => $asset->filename, - 'includeSubfolders' => '0', - 'sourcePathStep' => "folder:$folder->uid", - ]); - - return $this->redirect($url); - } - - /** - * Returns the total number of assets, and their total file size, based on their IDs and/or folder IDs. - * - * @throws BadRequestHttpException - * - * @since 5.7.0 - */ - public function actionMoveInfo(): Response - { - $this->requireCpRequest(); - $this->requirePostRequest(); - - $folderIds = Craft::$app->getRequest()->getBodyParam('folderIds', []); - $assetIds = Craft::$app->getRequest()->getBodyParam('assetIds', []); - - if (!empty($folderIds)) { - // Add descendant folders - $assetsService = Craft::$app->getAssets(); - foreach ($folderIds as $folderId) { - $folder = $assetsService->getFolderById($folderId); - if (!$folder) { - throw new BadRequestHttpException("Invalid folder ID: $folderId"); - } - $descendants = $assetsService->getAllDescendantFolders($folder); - array_push($folderIds, ...array_keys($descendants)); - } - } - - $query = \Illuminate\Support\Facades\DB::table(Table::ASSETS) - ->whereIn('id', $assetIds) - ->orWhereIn('folderId', array_unique($folderIds)); - - $count = $query->count(); - $totalSize = (int) $query->sum('size'); - - return $this->asJson([ - 'count' => $count, - 'totalSize' => $totalSize, - ]); - } } diff --git a/yii2-adapter/legacy/controllers/AssetsControllerTrait.php b/yii2-adapter/legacy/controllers/AssetsControllerTrait.php index 432e0e27e61..5e3fec75774 100644 --- a/yii2-adapter/legacy/controllers/AssetsControllerTrait.php +++ b/yii2-adapter/legacy/controllers/AssetsControllerTrait.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Exceptions\VolumeException; +use CraftCms\Cms\Support\Facades\Assets; use yii\base\InvalidConfigException; use yii\web\ForbiddenHttpException; @@ -19,6 +20,7 @@ * * @author Pixel & Tonic, Inc. * @since 4.5.0 + * @deprecated 6.0.0 */ trait AssetsControllerTrait { @@ -34,9 +36,9 @@ trait AssetsControllerTrait public function requireVolumePermissionByAsset(string $permissionName, Asset $asset): void { if (!$asset->getVolumeId()) { - $userTemporaryFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); + $userTemporaryFolder = Assets::getUserTemporaryUploadFolder(); - // Skip permission check only if it’s the user’s temporary folder + // Skip permission check only if it's the user's temporary folder if ($userTemporaryFolder->id == $asset->folderId) { return; } @@ -75,9 +77,9 @@ public function requirePeerVolumePermissionByAsset(string $permissionName, Asset public function requireVolumePermissionByFolder(string $permissionName, VolumeFolder $folder): void { if (!$folder->volumeId) { - $userTemporaryFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); + $userTemporaryFolder = Assets::getUserTemporaryUploadFolder(); - // Skip permission check only if it’s the user’s temporary folder + // Skip permission check only if it's the user's temporary folder if ($userTemporaryFolder->id == $folder->id) { return; } diff --git a/yii2-adapter/legacy/elements/db/AssetQuery.php b/yii2-adapter/legacy/elements/db/AssetQuery.php index 48e27343b51..c592518950e 100644 --- a/yii2-adapter/legacy/elements/db/AssetQuery.php +++ b/yii2-adapter/legacy/elements/db/AssetQuery.php @@ -946,8 +946,8 @@ protected function beforePrepare(): bool $folderCondition = Db::parseNumericParam('assets.folderId', $this->folderId); if (is_numeric($this->folderId) && $this->includeSubfolders) { - $assetsService = Craft::$app->getAssets(); - $descendants = $assetsService->getAllDescendantFolders($assetsService->getFolderById($this->folderId)); + $folders = app(\CraftCms\Cms\Asset\Folders::class); + $descendants = $folders->getAllDescendantFolders($folders->getFolderById($this->folderId)); $folderCondition = ['or', $folderCondition, ['in', 'assets.folderId', array_keys($descendants)]]; } $this->subQuery->andWhere($folderCondition); diff --git a/yii2-adapter/legacy/errors/AssetOperationException.php b/yii2-adapter/legacy/errors/AssetOperationException.php index 8854607c9d2..8a54d66ac51 100644 --- a/yii2-adapter/legacy/errors/AssetOperationException.php +++ b/yii2-adapter/legacy/errors/AssetOperationException.php @@ -7,19 +7,25 @@ namespace craft\errors; -/** - * Class AssetLogicException - * - * @author Pixel & Tonic, Inc. - * @since 3.0.0 - */ -class AssetOperationException extends AssetException -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @return string the user-friendly name of this exception + * Class AssetLogicException + * + * @author Pixel & Tonic, Inc. + * @since 3.0.0 + * @deprecated in 6.0.0 use {@see \CraftCms\Cms\Asset\Exceptions\AssetOperationException} instead. */ - public function getName(): string + class AssetOperationException extends AssetException { - return 'Asset Logic Error'; + /** + * @return string the user-friendly name of this exception + */ + public function getName(): string + { + return 'Asset Logic Error'; + } } } + +class_alias(\CraftCms\Cms\Asset\Exceptions\AssetOperationException::class, AssetOperationException::class); diff --git a/yii2-adapter/legacy/events/AssetPreviewEvent.php b/yii2-adapter/legacy/events/AssetPreviewEvent.php index 9f749795e75..61b8c46105f 100644 --- a/yii2-adapter/legacy/events/AssetPreviewEvent.php +++ b/yii2-adapter/legacy/events/AssetPreviewEvent.php @@ -7,8 +7,8 @@ namespace craft\events; -use craft\base\AssetPreviewHandlerInterface; use craft\base\Event; +use CraftCms\Cms\Asset\Contracts\AssetPreviewHandlerInterface; use CraftCms\Cms\Asset\Elements\Asset; /** diff --git a/yii2-adapter/legacy/gql/resolvers/mutations/Asset.php b/yii2-adapter/legacy/gql/resolvers/mutations/Asset.php index af8057871ed..a8d45146237 100644 --- a/yii2-adapter/legacy/gql/resolvers/mutations/Asset.php +++ b/yii2-adapter/legacy/gql/resolvers/mutations/Asset.php @@ -63,7 +63,8 @@ public function saveAsset(mixed $source, array $arguments, mixed $context, Resol $elementService = Craft::$app->getElements(); $newFolderId = $arguments['newFolderId'] ?? null; - $assetService = Craft::$app->getAssets(); + // Legacy service, tests depend on it + $folders = Craft::$app->getAssets(); if ($canIdentify) { $this->requireSchemaAction('volumes.' . $volume->uid, 'save'); @@ -90,7 +91,7 @@ public function saveAsset(mixed $source, array $arguments, mixed $context, Resol } if (empty($newFolderId)) { - $newFolderId = $assetService->getRootFolderByVolumeId($volume->id)->id; + $newFolderId = $folders->getRootFolderByVolumeId($volume->id)->id; } $asset = $elementService->createElement([ @@ -103,10 +104,10 @@ public function saveAsset(mixed $source, array $arguments, mixed $context, Resol if (empty($newFolderId)) { if (!$canIdentify) { /** @var \CraftCms\Cms\Asset\Elements\Asset $asset */ - $asset->newFolderId = $assetService->getRootFolderByVolumeId($volume->id)->id; + $asset->newFolderId = $folders->getRootFolderByVolumeId($volume->id)->id; } } else { - $folder = $assetService->getFolderById($newFolderId); + $folder = $folders->getFolderById($newFolderId); if (!$folder || $folder->volumeId != $volume->id) { throw new UserError('Invalid folder id provided'); @@ -117,16 +118,19 @@ public function saveAsset(mixed $source, array $arguments, mixed $context, Resol $asset->setVolumeId($volume->id); $asset = $this->populateElementWithData($asset, $arguments, $resolveInfo); + + // Legacy Yii2 event handling — must remain on legacy service + $legacyAssets = Craft::$app->getAssets(); $triggerReplaceEvents = ( $asset->getScenario() === AssetElement::SCENARIO_REPLACE && ( - $assetService->hasEventHandlers(Assets::EVENT_BEFORE_REPLACE_ASSET) || - $assetService->hasEventHandlers(Assets::EVENT_AFTER_REPLACE_ASSET) + $legacyAssets->hasEventHandlers(Assets::EVENT_BEFORE_REPLACE_ASSET) || + $legacyAssets->hasEventHandlers(Assets::EVENT_AFTER_REPLACE_ASSET) ) ); if ($triggerReplaceEvents) { - $assetService->trigger(Assets::EVENT_BEFORE_REPLACE_ASSET, new ReplaceAssetEvent([ + $legacyAssets->trigger(Assets::EVENT_BEFORE_REPLACE_ASSET, new ReplaceAssetEvent([ 'asset' => $asset, 'replaceWith' => $asset->tempFilePath, 'filename' => $this->filename, @@ -136,7 +140,7 @@ public function saveAsset(mixed $source, array $arguments, mixed $context, Resol $asset = $this->saveElement($asset); if ($triggerReplaceEvents) { - $assetService->trigger(Assets::EVENT_AFTER_REPLACE_ASSET, new ReplaceAssetEvent([ + $legacyAssets->trigger(Assets::EVENT_AFTER_REPLACE_ASSET, new ReplaceAssetEvent([ 'asset' => $asset, 'filename' => $this->filename, ])); diff --git a/yii2-adapter/legacy/helpers/Assets.php b/yii2-adapter/legacy/helpers/Assets.php index 1f23ed300eb..7de7d3f8a37 100644 --- a/yii2-adapter/legacy/helpers/Assets.php +++ b/yii2-adapter/legacy/helpers/Assets.php @@ -26,7 +26,9 @@ use CraftCms\Cms\Shared\Enums\TimePeriod; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Env; +use CraftCms\Cms\Support\Facades\Assets as AssetsFacade; use CraftCms\Cms\Support\Facades\Filesystems; +use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\PHP; use CraftCms\Cms\Support\Str; @@ -303,8 +305,7 @@ public static function filename2Title(string $filename): string */ public static function mirrorFolderStructure(VolumeFolder $sourceParentFolder, VolumeFolder $destinationFolder, array $targetTreeMap = []): array { - $assets = Craft::$app->getAssets(); - $sourceTree = $assets->getAllDescendantFolders($sourceParentFolder); + $sourceTree = Folders::getAllDescendantFolders($sourceParentFolder); $previousParent = $sourceParentFolder->getParent(); $sourcePrefixLength = strlen($previousParent->path); $folderIdChanges = []; @@ -323,7 +324,7 @@ public static function mirrorFolderStructure(VolumeFolder $sourceParentFolder, V // Any and all parent folders should be already mirrored $folder->parentId = ($folderIdChanges[$sourceFolder->parentId] ?? $destinationFolder->id); - $assets->createFolder($folder); + Folders::createFolder($folder); $folderIdChanges[$sourceFolder->id] = $folder->id; } @@ -741,7 +742,7 @@ private static function _buildFileKinds(): void */ public static function getImageEditorSource(int $assetId, int $size): string|false { - $asset = Craft::$app->getAssets()->getAssetById($assetId); + $asset = AssetsFacade::getAssetById($assetId); if (!$asset || !Image::canManipulateAsImage($asset->getExtension())) { return false; @@ -1020,8 +1021,7 @@ private static function normalizedTempUploadTarget(): ?string */ public static function resolveSubpath(Volume $volume, ?string $subpath, ?ElementInterface $element = null): array { - $assetsService = Craft::$app->getAssets(); - $rootFolder = $assetsService->getRootFolderByVolumeId($volume->id); + $rootFolder = Folders::getRootFolderByVolumeId($volume->id); // Are we looking for the root folder? $subpath = trim($subpath ?? '', '/'); @@ -1060,7 +1060,7 @@ public static function resolveSubpath(Volume $volume, ?string $subpath, ?Element $subpath = implode('/', $segments); } - $folder = $assetsService->findFolder([ + $folder = Folders::findFolder([ 'volumeId' => $volume->id, 'path' => $subpath . '/', ]); diff --git a/yii2-adapter/legacy/helpers/ImageTransforms.php b/yii2-adapter/legacy/helpers/ImageTransforms.php index d9cf6da1201..ba99b0ea589 100644 --- a/yii2-adapter/legacy/helpers/ImageTransforms.php +++ b/yii2-adapter/legacy/helpers/ImageTransforms.php @@ -12,10 +12,10 @@ use Craft; use craft\base\Image as BaseImage; use craft\errors\AssetException; -use craft\errors\AssetOperationException; use craft\image\Raster; use craft\models\ImageTransform; use CraftCms\Cms\Asset\Elements\Asset; +use CraftCms\Cms\Asset\Exceptions\AssetOperationException; use CraftCms\Cms\Asset\Exceptions\ImageException; use CraftCms\Cms\Asset\Exceptions\ImageTransformException; use CraftCms\Cms\Cms; diff --git a/yii2-adapter/legacy/imagetransforms/ImageTransformer.php b/yii2-adapter/legacy/imagetransforms/ImageTransformer.php index 072c8dfd653..14f45c697a1 100644 --- a/yii2-adapter/legacy/imagetransforms/ImageTransformer.php +++ b/yii2-adapter/legacy/imagetransforms/ImageTransformer.php @@ -445,7 +445,7 @@ protected function procureTransformedImage(ImageTransformIndex $index): bool */ private function generateTransform(ImageTransformIndex $index): void { - $asset = Craft::$app->getAssets()->getAssetById($index->assetId); + $asset = app(\CraftCms\Cms\Asset\Assets::class)->getAssetById($index->assetId); if (!$asset) { throw new ImageTransformException('Asset not found - ' . $index->assetId); diff --git a/yii2-adapter/legacy/services/Assets.php b/yii2-adapter/legacy/services/Assets.php index 20170cac3c6..4ba026dc9f8 100644 --- a/yii2-adapter/legacy/services/Assets.php +++ b/yii2-adapter/legacy/services/Assets.php @@ -10,55 +10,31 @@ namespace craft\services; use Craft; -use craft\assetpreviews\Image as ImagePreview; -use craft\assetpreviews\Pdf; -use craft\assetpreviews\Text; -use craft\assetpreviews\Video; -use craft\base\AssetPreviewHandlerInterface; use craft\db\Query; use craft\db\Table; use craft\elements\db\AssetQuery; -use craft\errors\AssetException; -use craft\errors\AssetOperationException; use craft\events\AssetPreviewEvent; + use craft\events\DefineAssetThumbUrlEvent; use craft\events\ReplaceAssetEvent; use craft\helpers\Assets as AssetsHelper; -use craft\helpers\DateTimeHelper; -use craft\helpers\Db; -use craft\helpers\FileHelper; -use craft\helpers\Image; -use craft\imagetransforms\FallbackTransformer; -use craft\models\ImageTransform; -use CraftCms\Cms\Asset\Data\FolderCriteria; +use CraftCms\Cms\Asset\Assets as AssetsService; +use CraftCms\Cms\Asset\Contracts\AssetPreviewHandlerInterface; use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; -use CraftCms\Cms\Asset\Exceptions\VolumeException; -use CraftCms\Cms\Asset\Models\VolumeFolder as VolumeFolderModel; -use CraftCms\Cms\Asset\Volumes; -use CraftCms\Cms\Cms; +use CraftCms\Cms\Asset\Events\AfterReplaceAsset; +use CraftCms\Cms\Asset\Events\BeforeReplaceAsset; +use CraftCms\Cms\Asset\Events\DefineThumbUrl; +use CraftCms\Cms\Asset\Events\RegisterPreviewHandler; +use CraftCms\Cms\Asset\Folders; use CraftCms\Cms\Filesystem\Contracts\FsInterface; -use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; -use CraftCms\Cms\Filesystem\Exceptions\FsObjectExistsException; -use CraftCms\Cms\Filesystem\Exceptions\FsObjectNotFoundException; -use CraftCms\Cms\Filesystem\Filesystems\Temp; -use CraftCms\Cms\Support\Env; -use CraftCms\Cms\Support\Facades\Filesystems; use CraftCms\Cms\Support\Json; -use CraftCms\Cms\Support\Str; use CraftCms\Cms\User\Elements\User; use Illuminate\Filesystem\FilesystemAdapter; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; -use InvalidArgumentException; -use Throwable; -use Tpetry\QueryExpressions\Language\Alias; +use Illuminate\Support\Facades\Event as EventFacade; use yii\base\Component; -use yii\base\InvalidConfigException; -use yii\base\NotSupportedException; use yii\db\Expression; -use function CraftCms\Cms\t; /** * Assets service. @@ -70,6 +46,7 @@ * @author Pixel & Tonic, Inc. * * @since 3.0.0 + * @deprecated in 6.0.0. Use {@see \CraftCms\Cms\Asset\Assets} and {@see \CraftCms\Cms\Asset\Folders} instead. */ class Assets extends Component { @@ -98,269 +75,46 @@ class Assets extends Component */ public const EVENT_REGISTER_PREVIEW_HANDLER = 'registerPreviewHandler'; - /** - * @var array - * - * @see getFolderById() - */ - private array $_foldersById = []; - - /** - * @var array - * - * @see getFolderByUid() - */ - private array $_foldersByUid = []; - - /** - * @var array - * - * @see getRootFolderByVolumeId() - */ - private array $_rootFolders = []; - - /** - * @var VolumeFolder[] - * - * @see getUserTemporaryUploadFolder() - */ - private array $_userTempFolders = []; - - /** - * Returns a file by its ID. - */ public function getAssetById(int $assetId, ?int $siteId = null): ?Asset { - return Craft::$app->getElements()->getElementById($assetId, Asset::class, $siteId); + return $this->assetsService()->getAssetById($assetId, $siteId); } - /** - * Gets the total number of assets that match a given criteria. - */ public function getTotalAssets(mixed $criteria = null): int { if ($criteria instanceof AssetQuery) { - $query = $criteria; - } else { - $query = Asset::find(); - if ($criteria) { - Craft::configure($query, $criteria); - } + return $criteria->count(); } - return $query->count(); + return $this->assetsService()->getTotalAssets($criteria); } - /** - * Replace an asset's file. - * - * @param string|null $mimeType The default MIME type to use, if it can’t be determined based on the server path - */ public function replaceAssetFile(Asset $asset, string $pathOnServer, string $filename, ?string $mimeType = null): void { - // Fire a 'beforeReplaceFile' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_REPLACE_ASSET)) { - $event = new ReplaceAssetEvent([ - 'asset' => $asset, - 'replaceWith' => $pathOnServer, - 'filename' => $filename, - ]); - $this->trigger(self::EVENT_BEFORE_REPLACE_ASSET, $event); - $filename = $event->filename; - } - - $asset->tempFilePath = $pathOnServer; - $asset->newFilename = $filename; - $asset->setMimeType(FileHelper::getMimeType($pathOnServer, checkExtension: false) ?? $mimeType); - $asset->uploaderId = Craft::$app->getUser()->getId(); - $asset->avoidFilenameConflicts = true; - $asset->setScenario(Asset::SCENARIO_REPLACE); - Craft::$app->getElements()->saveElement($asset); - - // Fire an 'afterReplaceFile' event - if ($this->hasEventHandlers(self::EVENT_AFTER_REPLACE_ASSET)) { - $this->trigger(self::EVENT_AFTER_REPLACE_ASSET, new ReplaceAssetEvent([ - 'asset' => $asset, - 'filename' => $filename, - ])); - } + $this->assetsService()->replaceAssetFile($asset, $pathOnServer, $filename, $mimeType); } - /** - * Move or rename an asset. - * - * @param Asset $asset The asset whose file should be renamed - * @param VolumeFolder $folder The volume folder to move the asset to. - * @param string $filename The new filename - * @return bool Whether the asset was renamed successfully - */ public function moveAsset(Asset $asset, VolumeFolder $folder, string $filename = ''): bool { - $folderChanging = $asset->folderId != $folder->id; - $filenameChanging = $filename !== '' && $filename !== $asset->getFilename(); - - if (!$folderChanging && !$filenameChanging) { - return true; - } - - if ($folderChanging) { - $asset->newFolderId = $folder->id; - } - - if ($filenameChanging) { - $asset->newFilename = $filename; - $asset->setScenario(Asset::SCENARIO_FILEOPS); - } else { - $asset->setScenario(Asset::SCENARIO_MOVE); - } - - return Craft::$app->getElements()->saveElement($asset); + return $this->assetsService()->moveAsset($asset, $folder, $filename); } - /** - * Save a volume folder. - * - * @throws FsObjectExistsException if a folder already exists with such a name - * @throws FilesystemException if unable to create the directory on volume - * @throws AssetException if invalid folder provided - */ public function createFolder(VolumeFolder $folder): void { - $parent = $folder->getParent(); - - if (!$parent) { - throw new AssetException('Folder ' . $folder->id . ' doesn’t have a parent.'); - } - - $existingFolder = $this->findFolder([ - 'parentId' => $folder->parentId, - 'name' => $folder->name, - ]); - - if ($existingFolder && (!$folder->id || $folder->id !== $existingFolder->id)) { - throw new FsObjectExistsException(t( - 'A folder with the name “{folderName}” already exists in the volume.', - ['folderName' => $folder->name] - )); - } - - $volume = $parent->getVolume(); - $path = rtrim($folder->path, '/'); - - if (!$volume->sourceDisk()->makeDirectory($path)) { - throw new FilesystemException("Unable to create directory at path: $path"); - } - - $this->storeFolderRecord($folder); + $this->foldersService()->createFolder($folder); } - /** - * Renames a folder by its ID. - * - * @return string The new folder name after cleaning it. - * - * @throws AssetOperationException If the folder to be renamed can't be found or trying to rename the top folder. - * @throws FsObjectExistsException - * @throws FsObjectNotFoundException - */ public function renameFolderById(int $folderId, string $newName): string { - $newName = AssetsHelper::prepareAssetName($newName, false); - $folder = $this->getFolderById($folderId); - - if (!$folder) { - throw new AssetOperationException(t('No folder exists with the ID “{id}”', [ - 'id' => $folderId, - ])); - } - - if (!$folder->parentId) { - throw new AssetOperationException(t('It’s not possible to rename the top folder of a Volume.')); - } - - $conflictingFolder = $this->findFolder([ - 'parentId' => $folder->parentId, - 'name' => $newName, - ]); - - if ($conflictingFolder) { - throw new FsObjectExistsException(t('A folder with the name “{folderName}” already exists in the folder.', [ - 'folderName' => $newName, - ])); - } - - $parentFolderPath = dirname($folder->path); - $newFolderPath = (($parentFolderPath && $parentFolderPath !== '.') ? $parentFolderPath . '/' : '') . $newName . '/'; - - $volume = $folder->getVolume(); - - $this->renameDirectoryOnDisk($volume, rtrim($folder->path, '/'), rtrim($newFolderPath, '/')); - $descendantFolders = $this->getAllDescendantFolders($folder); - - foreach ($descendantFolders as $descendantFolder) { - $descendantFolder->path = preg_replace('#^' . $folder->path . '#', $newFolderPath, $descendantFolder->path); - $this->storeFolderRecord($descendantFolder); - } - - // Now change the affected folder - $folder->name = $newName; - $folder->path = $newFolderPath; - $this->storeFolderRecord($folder); - - return $newName; + return $this->foldersService()->renameFolderById($folderId, $newName); } - /** - * Deletes a folder by its ID. - * - * @param bool $deleteDir Should the volume directory be deleted along the record, if applicable. Defaults to true. - * - * @throws InvalidConfigException if the volume cannot be fetched from folder. - */ public function deleteFoldersByIds(int|array $folderIds, bool $deleteDir = true): void { - $allFolderIds = []; - - foreach ((array) $folderIds as $folderId) { - $folder = $this->getFolderById((int) $folderId); - if (!$folder) { - continue; - } - - $allFolderIds[] = $folder->id; - $descendants = $this->getAllDescendantFolders($folder, withParent: false); - array_push($allFolderIds, ...array_map(fn(VolumeFolder $folder) => $folder->id, $descendants)); - - // Delete the directory on the filesystem - if ($folder->path && $deleteDir) { - $volume = $folder->getVolume(); - try { - $volume->sourceDisk()->deleteDirectory(trim($folder->path, '/')); - } catch (Throwable $exception) { - Craft::$app->getErrorHandler()->logException($exception); - // Carry on. - } - } - } - - // Delete the elements - $assetQuery = Asset::find()->folderId($allFolderIds); - $elementService = Craft::$app->getElements(); - - $assetQuery->each(function(Asset $asset) use ($deleteDir, $elementService) { - $asset->keepFileOnDelete = !$deleteDir; - $elementService->deleteElement($asset, true); - }, 100); - - // Delete the folder records - VolumeFolderModel::whereIn('id', $allFolderIds)->delete(); + $this->foldersService()->deleteFoldersByIds($folderIds, $deleteDir); } /** - * Returns a list of hierarchical folders for the given volume IDs, indexed by volume ID. - * - * @param array $additionalCriteria additional criteria for filtering the tree - * * @deprecated in 4.4.0 */ public function getFolderTreeByVolumeIds(array $volumeIds, array $additionalCriteria = []): array @@ -369,19 +123,24 @@ public function getFolderTreeByVolumeIds(array $volumeIds, array $additionalCrit $tree = []; - // Get the tree for each source foreach ($volumeIds as $volumeId) { - // Add additional criteria but prevent overriding volumeId and order. $criteria = array_merge($additionalCriteria, [ 'volumeId' => $volumeId, 'order' => [new Expression('[[path]] IS NULL DESC'), 'path' => SORT_ASC], ]); $cacheKey = md5(Json::encode($criteria)); - // If this has not been yet fetched, fetch it. if (empty($volumeFolders[$cacheKey])) { $folders = $this->findFolders($criteria); - $subtree = $this->_getFolderTreeByFolders($folders); + + if (empty($folders)) { + continue; + } + + $subtree = $this->foldersService()->getAllDescendantFolders( + reset($folders), + asTree: true, + ); $volumeFolders[$cacheKey] = reset($subtree); } @@ -394,8 +153,6 @@ public function getFolderTreeByVolumeIds(array $volumeIds, array $additionalCrit } /** - * Returns the folder tree for assets by a folder ID. - * * @deprecated in 4.4.0 */ public function getFolderTreeByFolderId(int $folderId): array @@ -404,88 +161,29 @@ public function getFolderTreeByFolderId(int $folderId): array return []; } - $childFolders = $this->getAllDescendantFolders($parentFolder); - - return $this->_getFolderTreeByFolders([$parentFolder] + $childFolders); + return $this->foldersService()->getAllDescendantFolders($parentFolder, asTree: true); } - /** - * Returns a folder by its ID. - */ public function getFolderById(int $folderId): ?VolumeFolder { - if (!array_key_exists($folderId, $this->_foldersById)) { - $result = $this->createFolderQuery() - ->where(['id' => $folderId]) - ->one(); - - $this->_foldersById[$folderId] = $result ? new VolumeFolder($result) : null; - } - - return $this->_foldersById[$folderId]; + return $this->foldersService()->getFolderById($folderId); } - /** - * Returns a folder by its UUID. - */ public function getFolderByUid(string $folderUid): ?VolumeFolder { - if (!array_key_exists($folderUid, $this->_foldersByUid)) { - $result = $this->createFolderQuery() - ->where(['uid' => $folderUid]) - ->one(); - - $this->_foldersByUid[$folderUid] = $result ? new VolumeFolder($result) : null; - } - - return $this->_foldersByUid[$folderUid]; + return $this->foldersService()->getFolderByUid($folderUid); } /** - * Finds folders that match a given criteria. - * * @return VolumeFolder[] */ public function findFolders(mixed $criteria = []): array { - if (!$criteria instanceof FolderCriteria) { - $criteria = new FolderCriteria($criteria); - } - - $query = $this->createFolderQuery(); - - $this->_applyFolderConditions($query, $criteria); - - if ($criteria->order) { - $query->orderBy($criteria->order); - } - - if ($criteria->offset) { - $query->offset($criteria->offset); - } - - if ($criteria->limit) { - $query->limit($criteria->limit); - } - - $results = $query->all(); - $folders = []; - - foreach ($results as $result) { - $folder = new VolumeFolder($result); - $this->_foldersById[$folder->id] = $folder; - $folders[$folder->id] = $folder; - } - - return $folders; + return $this->foldersService()->findFolders($criteria)->all(); } /** - * Returns all of the folders that are descendants of a given folder. - * - * @param bool $withParent Whether the parent folder should be included in the results - * @param bool $asTree Whether the folders should be returned hierarchically - * @return array The descendant folders, indexed by their IDs + * @return array */ public function getAllDescendantFolders( VolumeFolder $parentFolder, @@ -493,143 +191,30 @@ public function getAllDescendantFolders( bool $withParent = true, bool $asTree = false, ): array { - $query = $this->createFolderQuery() - ->where([ - 'and', - ['volumeId' => $parentFolder->volumeId], - ['not', ['parentId' => null]], - ]); - - if ($parentFolder->path !== null) { - $query->andWhere(['like', 'path', \CraftCms\Cms\Support\Query::escapeForLike($parentFolder->path) . '%', false]); - } - - if ($orderBy) { - $query->orderBy($orderBy); - } - - if (!$withParent) { - $query->andWhere(['not', ['id' => $parentFolder->id]]); - } - - $results = $query->all(); - $descendantFolders = []; - - foreach ($results as $result) { - $folder = new VolumeFolder($result); - $this->_foldersById[$folder->id] = $folder; - $descendantFolders[$folder->id] = $folder; - } - - if ($asTree) { - return $this->_getFolderTreeByFolders($descendantFolders); - } - - return $descendantFolders; + return $this->foldersService()->getAllDescendantFolders($parentFolder, $orderBy, $withParent, $asTree); } - /** - * Finds the first folder that matches a given criteria. - */ public function findFolder(mixed $criteria = []): ?VolumeFolder { - if (!$criteria instanceof FolderCriteria) { - $criteria = new FolderCriteria($criteria); - } - - $criteria->limit = 1; - $folder = $this->findFolders($criteria); - - if (!empty($folder)) { - return array_pop($folder); - } - - return null; + return $this->foldersService()->findFolder($criteria); } - /** - * Returns the root folder for a given volume ID. - * - * @param int $volumeId The volume ID - * @return VolumeFolder|null The root folder in that volume, or null if the volume doesn’t exist - */ public function getRootFolderByVolumeId(int $volumeId): ?VolumeFolder { - if (!array_key_exists($volumeId, $this->_rootFolders)) { - $volume = app(Volumes::class)->getVolumeById($volumeId); - if (!$volume) { - // todo: throw an InvalidArgumentException - return $this->_rootFolders[$volumeId] = null; - } - - $folder = $this->findFolder([ - 'volumeId' => $volumeId, - 'parentId' => ':empty:', - ]); - - if (!$folder) { - $folder = new VolumeFolder(); - $folder->volumeId = $volume->id; - $folder->parentId = null; - $folder->name = $volume->name; - $folder->path = ''; - $this->storeFolderRecord($folder); - } - - $this->_rootFolders[$volumeId] = $folder; - } - - return $this->_rootFolders[$volumeId]; + return $this->foldersService()->getRootFolderByVolumeId($volumeId); } - /** - * Gets the total number of folders that match a given criteria. - */ public function getTotalFolders(mixed $criteria): int { - if (!$criteria instanceof FolderCriteria) { - $criteria = new FolderCriteria($criteria); - } - - $query = (new Query()) - ->from([Table::VOLUMEFOLDERS]); - - $this->_applyFolderConditions($query, $criteria); - - return (int) $query->count('[[id]]'); + return $this->foldersService()->getTotalFolders($criteria); } - /** - * Returns whether any folders exist which match a given criteria. - * - * @param mixed $criteria - * - * @since 4.4.0 - */ public function foldersExist($criteria = null): bool { - if (!($criteria instanceof FolderCriteria)) { - $criteria = new FolderCriteria($criteria); - } - - $query = (new Query()) - ->from([Table::VOLUMEFOLDERS]); - - $this->_applyFolderConditions($query, $criteria); - - return $query->exists(); + return $this->foldersService()->foldersExist($criteria); } - // File and folder managing - // ------------------------------------------------------------------------- - /** - * Returns the URL for an asset, possibly with a given transform applied. - * - * @param ImageTransform|string|array|null $transform - * - * @throws InvalidConfigException - * * @deprecated in 4.0.0. [[Asset::getUrl()]] should be used instead. */ public function getAssetUrl(Asset $asset, mixed $transform = null): ?string @@ -637,105 +222,17 @@ public function getAssetUrl(Asset $asset, mixed $transform = null): ?string return $asset->getUrl($transform); } - /** - * Returns the control panel thumbnail URL for a given asset. - * - * @param Asset $asset asset to return a thumb for - * @param int $width width of the returned thumb - * @param int|null $height height of the returned thumb (defaults to $width if null) - * @param bool $iconFallback Whether an icon URL fallback should be returned as a fallback - */ public function getThumbUrl(Asset $asset, int $width, ?int $height = null, $iconFallback = true): ?string { - if ($height === null) { - $height = $width; - } - - // Fire a 'defineThumbUrl' event - if ($this->hasEventHandlers(self::EVENT_DEFINE_THUMB_URL)) { - $event = new DefineAssetThumbUrlEvent([ - 'asset' => $asset, - 'width' => $width, - 'height' => $height, - ]); - $this->trigger(self::EVENT_DEFINE_THUMB_URL, $event); - // If a plugin set the url, we'll just use that. - if ($event->url !== null) { - return $event->url; - } - } - - // If it’s not an image, return a generic file extension icon - $extension = $asset->getExtension(); - if (!Image::canManipulateAsImage($extension)) { - return $iconFallback ? AssetsHelper::iconUrl($extension) : null; - } - - $transform = Craft::createObject([ - 'class' => ImageTransform::class, - 'width' => $width, - 'height' => $height, - 'mode' => 'crop', - ]); - - $url = $asset->getUrl($transform); - - if (!$url) { - // Try again with the fallback transformer - $transform->setTransformer(FallbackTransformer::class); - $url = $asset->getUrl($transform); - } - - if ($url === null) { - return $iconFallback ? AssetsHelper::iconUrl($extension) : null; - } - - return AssetsHelper::revUrl($url, $asset, fsOnly: true); + return $this->assetsService()->getThumbUrl($asset, $width, $height, $iconFallback); } - /** - * Returns an image asset’s URL, scaled to fit within a max width and height. - * - * @throws NotSupportedException if the asset’s volume doesn’t have a filesystem with public URLs - * - * @since 4.0.0 - */ public function getImagePreviewUrl(Asset $asset, int $maxWidth, int $maxHeight): string { - $isWebSafe = Image::isWebSafe($asset->getExtension()); - $originalWidth = (int) $asset->getWidth(); - $originalHeight = (int) $asset->getHeight(); - [$width, $height] = AssetsHelper::scaledDimensions((int) $asset->getWidth(), (int) $asset->getHeight(), $maxWidth, $maxHeight); - - if ( - !$isWebSafe || - !$asset->getVolume()->getFs()->hasUrls || - $originalWidth > $width || - $originalHeight > $height - ) { - $transform = Craft::createObject([ - 'class' => ImageTransform::class, - 'width' => $width, - 'height' => $height, - 'mode' => 'crop', - ]); - } else { - $transform = null; - } - - $url = $asset->getUrl($transform, true); - - if (!$url) { - throw new NotSupportedException('A preview URL couldn’t be generated for the asset.'); - } - - return AssetsHelper::revUrl($url, $asset, fsOnly: true); + return $this->assetsService()->getImagePreviewUrl($asset, $maxWidth, $maxHeight); } /** - * Returns a generic file extension icon path, that can be used as a fallback - * for assets that don't have a normal thumbnail. - * * @deprecated in 4.0.0. [[AssetsHelper::iconSvg()]] or [[Asset::getThumbSvg()]] should be used instead. */ public function getIconPath(Asset $asset): string @@ -743,223 +240,31 @@ public function getIconPath(Asset $asset): string return AssetsHelper::iconPath($asset->getExtension()); } - /** - * Find a replacement for a filename - * - * @param string $originalFilename the original filename for which to find a replacement. - * @param int $folderId The folder in which to find the replacement - * @return string If a suitable filename replacement cannot be found. - * - * @throws AssetOperationException If a suitable filename replacement cannot be found. - * @throws InvalidConfigException - * @throws VolumeException - */ public function getNameReplacementInFolder(string $originalFilename, int $folderId): string { - $folder = $this->getFolderById($folderId); - - if (!$folder) { - throw new InvalidArgumentException('Invalid folder ID: ' . $folderId); - } - - $volume = $folder->getVolume(); - - // A potentially conflicting filename is one that shares the same stem and extension - - // Check for potentially conflicting files in index. - $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); - - $buildFilename = function(string $name, string $suffix = '') use ($extension) { - $maxLength = 255 - strlen($suffix); - if ($extension !== '') { - $maxLength -= strlen($extension) + 1; - } - if (strlen($name) > $maxLength) { - $name = substr($name, 0, $maxLength); - } - - return $name . $suffix; - }; - - $baseFileName = $buildFilename(pathinfo($originalFilename, PATHINFO_FILENAME)); - - $dbFileList = \Illuminate\Support\Facades\DB::table(new Alias(\CraftCms\Cms\Database\Table::ASSETS, 'assets')) - ->join(new Alias(\CraftCms\Cms\Database\Table::ELEMENTS, 'elements'), 'elements.id', 'assets.id') - ->where('assets.folderId', $folderId) - ->whereNull('elements.dateDeleted') - ->whereLike('assets.filename', $baseFileName . '%.' . $extension) - ->pluck('assets.filename'); - - $potentialConflicts = []; - - foreach ($dbFileList as $filename) { - $potentialConflicts[mb_strtolower($filename)] = true; - } - - // Check whether a filename we'd want to use does not exist - $canUse = static fn($filenameToTest) => !isset($potentialConflicts[mb_strtolower($filenameToTest)]) && !$volume->sourceDisk()->exists($folder->path . $filenameToTest); - - if ($canUse($originalFilename)) { - return $originalFilename; - } - - // If the file already ends with something that looks like a timestamp, use that instead. - if (preg_match('/.*_\d{4}-\d{2}-\d{2}-\d{6}$/', $baseFileName, $matches)) { - $base = $baseFileName; - } else { - $timestamp = DateTimeHelper::currentUTCDateTime()->format('Y-m-d-His'); - $base = $buildFilename($baseFileName, '_' . $timestamp); - } - - // Append a random string at the end too, to avoid race-conditions - $base = $buildFilename($base, sprintf('_%s', Str::random(4))); - - $increment = 0; - - while (true) { - // Add the increment (if > 0) and keep the full filename w/ increment & extension from going over 255 chars - $suffix = $increment ? "_$increment" : ''; - $newFilename = $buildFilename($base, $suffix) . ($extension !== '' ? ".$extension" : ''); - - if ($canUse($newFilename)) { - break; - } - - if ($increment === 50) { - throw new AssetOperationException(t('Could not find a suitable replacement filename for “{filename}”.', [ - 'filename' => $originalFilename, - ])); - } - - $increment++; - } - - return $newFilename; + return $this->assetsService()->getNameReplacementInFolder($originalFilename, $folderId); } - /** - * Ensures a folder entry exists in the DB for the full path. Depending on the use, it’s also possible to ensure a physical folder exists. - * - * @param string $fullPath The path to ensure the folder exists at. - * @param bool $justRecord If set to false, will also make sure the physical folder exists on the volume. - * - * @throws VolumeException if something went catastrophically wrong creating the folder. - */ public function ensureFolderByFullPathAndVolume(string $fullPath, Volume $volume, bool $justRecord = true): VolumeFolder { - $parentFolder = $this->getRootFolderByVolumeId($volume->id); - $folderModel = $parentFolder; - $parentId = $parentFolder->id; - - $fullPath = trim($fullPath, '/\\'); - - if ($fullPath !== '') { - // If we don't have a folder matching these, create a new one - $parts = preg_split('/\\\\|\//', $fullPath); - - // creep up the folder path - $path = ''; - - while (($part = array_shift($parts)) !== null) { - $path .= $part . '/'; - - $parameters = new FolderCriteria([ - 'path' => $path, - 'volumeId' => $volume->id, - ]); - - // Create the record for current segment if needed. - if (($folderModel = $this->findFolder($parameters)) === null) { - $folderModel = new VolumeFolder(); - $folderModel->volumeId = $volume->id; - $folderModel->parentId = $parentId; - $folderModel->name = $part; - $folderModel->path = $path; - $this->storeFolderRecord($folderModel); - } - - // Ensure a physical folder exists, if needed. - if (!$justRecord) { - if (!$volume->sourceDisk()->makeDirectory($path)) { - throw new FilesystemException("Unable to create directory at path: $path"); - } - } - - // Set the variables for next iteration. - $folderId = $folderModel->id; - $parentId = $folderId; - } - } - - return $folderModel; + return $this->foldersService()->ensureFolderByFullPathAndVolume($fullPath, $volume, $justRecord); } - /** - * Store a folder by model - */ public function storeFolderRecord(VolumeFolder $folder): void { - if (!$folder->id) { - $model = new VolumeFolderModel(); - } else { - $model = VolumeFolderModel::findOrFail($folder->id); - } - - $model->parentId = $folder->parentId; - $model->volumeId = $folder->volumeId; - $model->name = $folder->name; - $model->path = $folder->path; - $model->save(); - - $folder->id = $model->id; - $folder->uid = $model->uid; + $this->foldersService()->storeFolderModel($folder); } - /** - * Get the Filesystem that should be used for temporary uploads. - * If one is not specified, use a local folder wrapped in a Temp FS. - * - * @throws InvalidConfigException - */ public function getTempAssetUploadFs(): FsInterface { - $handle = Env::parse(Cms::config()->tempAssetUploadFs); - if (!$handle) { - return new Temp(); - } - - return Filesystems::resolve($handle) - ?? throw new InvalidConfigException("The tempAssetUploadFs config setting is set to an invalid filesystem value: $handle"); + return $this->assetsService()->getTempAssetUploadFs(); } - /** - * Get the Laravel disk that should be used for temporary uploads. - * - * @throws InvalidConfigException - */ public function getTempAssetUploadDisk(): FilesystemAdapter { - $handle = Env::parse(Cms::config()->tempAssetUploadFs); - if (!$handle) { - return Storage::build([ // @phpstan-ignore return.type - 'driver' => 'local', - 'root' => Craft::$app->getPath()->getTempAssetUploadsPath(), - ]); - } - - return Storage::disk( - Filesystems::resolveDiskName($handle) - ?? throw new InvalidConfigException("The tempAssetUploadFs config setting is set to an invalid filesystem value: $handle") - ); + return $this->assetsService()->getTempAssetUploadDisk(); } - /** - * Creates an asset query that is configured to return assets in the temporary upload location. - * - * @throws InvalidConfigException If the temp volume is invalid - * - * @since 3.7.39 - */ public function createTempAssetQuery(): AssetQuery { $query = new AssetQuery(Asset::class); @@ -968,163 +273,17 @@ public function createTempAssetQuery(): AssetQuery return $query; } - /** - * Returns the given user’s temporary upload folder. - * - * If no user is provided, the currently-logged in user will be used (if there is one), or a folder named after - * the current session ID. - * - * @throws VolumeException - */ public function getUserTemporaryUploadFolder(?User $user = null): VolumeFolder { - // Default to the logged-in user, if there is one - $user ??= Auth::user(); - $cacheKey = $user->id ?? '__GUEST__'; - - if (isset($this->_userTempFolders[$cacheKey])) { - return $this->_userTempFolders[$cacheKey]; - } - - if ($user) { - $folderName = 'user_' . $user->id; - } elseif (Craft::$app->getRequest()->getIsConsoleRequest()) { - // For console requests, just make up a folder name. - $folderName = 'temp_' . sha1((string) time()); - } else { - // A little obfuscation never hurt anyone - $folderName = 'user_' . sha1(Craft::$app->getSession()->id); - } - - $volumeTopFolder = $this->findFolder([ - 'volumeId' => ':empty:', - 'parentId' => ':empty:', - ]); - - if (!$volumeTopFolder) { - $volumeTopFolder = new VolumeFolder(); - $volumeTopFolder->name = t('Temporary Uploads'); - $this->storeFolderRecord($volumeTopFolder); - } - - $folder = $this->findFolder([ - 'name' => $folderName, - 'parentId' => $volumeTopFolder->id, - ]); - - if (!$folder) { - $folder = new VolumeFolder(); - $folder->parentId = $volumeTopFolder->id; - $folder->name = $folderName; - $folder->path = $folderName . '/'; - $this->storeFolderRecord($folder); - } - - $disk = $this->getTempAssetUploadDisk(); - - try { - if (!$disk->directoryExists($folderName) && !$disk->makeDirectory($folderName)) { - throw new VolumeException('Unable to create directory for temporary uploads.'); - } - } catch (Throwable) { - throw new VolumeException('Unable to create directory for temporary uploads.'); - } - - $folder->name = t('Temporary Uploads'); - - return $this->_userTempFolders[$cacheKey] = $folder; + return $this->assetsService()->getUserTemporaryUploadFolder($user); } - /** - * Returns the asset preview handler for a given asset, or `null` if the asset is not previewable. - * - * @since 3.4.0 - */ public function getAssetPreviewHandler(Asset $asset): ?AssetPreviewHandlerInterface { - // Fire a 'registerPreviewHandler' event - if ($this->hasEventHandlers(self::EVENT_REGISTER_PREVIEW_HANDLER)) { - $event = new AssetPreviewEvent(['asset' => $asset]); - $this->trigger(self::EVENT_REGISTER_PREVIEW_HANDLER, $event); - if ($event->previewHandler instanceof AssetPreviewHandlerInterface) { - return $event->previewHandler; - } - } - - // These are our default preview handlers if one is not supplied - return match ($asset->kind) { - Asset::KIND_IMAGE => new ImagePreview($asset), - Asset::KIND_PDF => new Pdf($asset), - Asset::KIND_VIDEO => new Video($asset), - Asset::KIND_HTML, Asset::KIND_JAVASCRIPT, Asset::KIND_JSON, Asset::KIND_PHP, Asset::KIND_TEXT, Asset::KIND_XML => new Text($asset), - default => null, - }; + return $this->assetsService()->getAssetPreviewHandler($asset); } /** - * Renames a directory by creating/moving/deleting when a direct directory rename is unavailable. - * - * @throws FilesystemException - * @throws FsObjectNotFoundException - */ - private function renameDirectoryOnDisk(Volume $volume, string $sourcePath, string $targetPath): void - { - $sourcePath = trim($sourcePath, '/'); - $targetPath = trim($targetPath, '/'); - - $disk = $volume->sourceDisk(); - - if ($sourcePath === '' || !$disk->directoryExists($sourcePath)) { - throw new FsObjectNotFoundException("No folder exists at path: $sourcePath"); - } - - if ($targetPath === '') { - throw new FilesystemException('New directory name cannot be empty.'); - } - - if ($targetPath === $sourcePath) { - return; - } - - if (!$disk->makeDirectory($targetPath)) { - throw new FilesystemException("Unable to create directory at path: $targetPath"); - } - - $directories = $disk->allDirectories($sourcePath); - usort($directories, fn(string $a, string $b) => substr_count($a, '/') <=> substr_count($b, '/')); - - foreach ($directories as $directory) { - $targetDirectory = preg_replace( - '/^' . preg_quote($sourcePath, '/') . '(?=\/|$)/', - $targetPath, - trim($directory, '/'), - 1, - ) ?? trim($directory, '/'); - - if (!$disk->makeDirectory($targetDirectory)) { - throw new FilesystemException("Unable to create directory at path: $targetDirectory"); - } - } - - foreach ($disk->allFiles($sourcePath) as $file) { - $targetFile = preg_replace( - '/^' . preg_quote($sourcePath, '/') . '(?=\/|$)/', - $targetPath, - trim($file, '/'), - 1, - ) ?? trim($file, '/'); - - if (!$disk->move($file, $targetFile)) { - throw new FilesystemException("Unable to move $file to $targetFile"); - } - } - - $disk->deleteDirectory($sourcePath); - } - - /** - * Returns a DbCommand object prepped for retrieving assets. - * * @since 4.4.0 */ public function createFolderQuery(): Query @@ -1134,68 +293,71 @@ public function createFolderQuery(): Query ->from([Table::VOLUMEFOLDERS]); } - /** - * Arranges the given array of folders hierarchically. - * - * @param VolumeFolder[] $folders - * @return VolumeFolder[] - */ - private function _getFolderTreeByFolders(array $folders): array + public static function registerEvents(): void { - $tree = []; - /** @var VolumeFolder[] $referenceStore */ - $referenceStore = []; - - foreach ($folders as $folder) { - // We'll be adding all of the children in this loop, anyway, so we set - // the children list to an empty array so that folders that have no children don't - // trigger any queries, when asked for children - $folder->setChildren([]); - if ($folder->parentId && isset($referenceStore[$folder->parentId])) { - $referenceStore[$folder->parentId]->addChild($folder); - } else { - $tree[] = $folder; + EventFacade::listen(BeforeReplaceAsset::class, function(BeforeReplaceAsset $event) { + if (!Craft::$app->getAssets()->hasEventHandlers(self::EVENT_BEFORE_REPLACE_ASSET)) { + return; } - $referenceStore[$folder->id] = $folder; - } + $yiiEvent = new ReplaceAssetEvent([ + 'asset' => $event->asset, + 'replaceWith' => $event->replaceWith, + 'filename' => $event->filename, + ]); + Craft::$app->getAssets()->trigger(self::EVENT_BEFORE_REPLACE_ASSET, $yiiEvent); + $event->filename = $yiiEvent->filename; + }); - return $tree; - } + EventFacade::listen(AfterReplaceAsset::class, function(AfterReplaceAsset $event) { + if (!Craft::$app->getAssets()->hasEventHandlers(self::EVENT_AFTER_REPLACE_ASSET)) { + return; + } - /** - * Applies WHERE conditions to a DbCommand query for folders. - */ - private function _applyFolderConditions(Query $query, FolderCriteria $criteria): void - { - if ($criteria->id) { - $query->andWhere(Db::parseNumericParam('id', $criteria->id)); - } + Craft::$app->getAssets()->trigger(self::EVENT_AFTER_REPLACE_ASSET, new ReplaceAssetEvent([ + 'asset' => $event->asset, + 'filename' => $event->filename, + ])); + }); - if ($criteria->volumeId) { - $query->andWhere(Db::parseNumericParam('volumeId', $criteria->volumeId)); - } + EventFacade::listen(DefineThumbUrl::class, function(DefineThumbUrl $event) { + if (!Craft::$app->getAssets()->hasEventHandlers(self::EVENT_DEFINE_THUMB_URL)) { + return; + } - if ($criteria->parentId) { - $query->andWhere(Db::parseNumericParam('parentId', $criteria->parentId)); - } + $yiiEvent = new DefineAssetThumbUrlEvent([ + 'asset' => $event->asset, + 'width' => $event->width, + 'height' => $event->height, + ]); + Craft::$app->getAssets()->trigger(self::EVENT_DEFINE_THUMB_URL, $yiiEvent); - if ($criteria->name) { - $query->andWhere(Db::parseParam('name', $criteria->name)); - } + if ($yiiEvent->url !== null) { + $event->url = $yiiEvent->url; + } + }); - if ($criteria->uid) { - $query->andWhere(Db::parseParam('uid', $criteria->uid)); - } + EventFacade::listen(RegisterPreviewHandler::class, function(RegisterPreviewHandler $event) { + if (!Craft::$app->getAssets()->hasEventHandlers(self::EVENT_REGISTER_PREVIEW_HANDLER)) { + return; + } - if ($criteria->path !== null) { - // Does the path have a comma in it? - if (str_contains($criteria->path, ',')) { - // Escape the comma. - $query->andWhere(Db::parseParam('path', str_replace(',', '\,', $criteria->path))); - } else { - $query->andWhere(Db::parseParam('path', $criteria->path)); + $yiiEvent = new AssetPreviewEvent(['asset' => $event->asset]); + Craft::$app->getAssets()->trigger(self::EVENT_REGISTER_PREVIEW_HANDLER, $yiiEvent); + + if ($yiiEvent->previewHandler instanceof AssetPreviewHandlerInterface) { + $event->previewHandler = $yiiEvent->previewHandler; } - } + }); + } + + private function assetsService(): AssetsService + { + return app(AssetsService::class); + } + + private function foldersService(): Folders + { + return app(Folders::class); } } diff --git a/yii2-adapter/legacy/services/Volumes.php b/yii2-adapter/legacy/services/Volumes.php index 553c4e2c48d..8c6044cd976 100644 --- a/yii2-adapter/legacy/services/Volumes.php +++ b/yii2-adapter/legacy/services/Volumes.php @@ -255,7 +255,7 @@ public function reorderVolumes(array $volumeIds): bool */ public function ensureTopFolder(Volume $volume): VolumeFolder { - $folder = Craft::$app->getAssets()->getRootFolderByVolumeId($volume->id); + $folder = app(\CraftCms\Cms\Asset\Folders::class)->getRootFolderByVolumeId($volume->id); if (!$folder) { throw new InvalidArgumentException(sprintf('Invalid volume passed to %s().', __METHOD__)); } diff --git a/yii2-adapter/legacy/test/Craft.php b/yii2-adapter/legacy/test/Craft.php index c9ed562f867..93ea44315ee 100644 --- a/yii2-adapter/legacy/test/Craft.php +++ b/yii2-adapter/legacy/test/Craft.php @@ -19,6 +19,8 @@ use craft\queue\BaseJob; use craft\queue\Queue; use craft\web\Application as WebApplication; +use CraftCms\Cms\Asset\Assets; +use CraftCms\Cms\Asset\Folders; use CraftCms\Cms\Asset\Volumes; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; @@ -185,6 +187,8 @@ public function _afterSuite(): void app()->forgetInstance(Fields::class); app()->forgetInstance(ProjectConfig::class); app()->forgetInstance(Users::class); + app()->forgetInstance(Assets::class); + app()->forgetInstance(Folders::class); } /** @@ -236,9 +240,13 @@ public function _after(TestInterface $test): void app()->forgetInstance(Sections::class); app()->forgetInstance(Filesystems::class); app()->forgetInstance(Volumes::class); + app()->forgetInstance(Assets::class); + app()->forgetInstance(Folders::class); \CraftCms\Cms\Support\Facades\EntryTypes::clearResolvedInstances(); \CraftCms\Cms\Support\Facades\Sections::clearResolvedInstances(); + \CraftCms\Cms\Support\Facades\Assets::clearResolvedInstances(); + \CraftCms\Cms\Support\Facades\Folders::clearResolvedInstances(); \Craft::$app->getDb()->close(); \Craft::$app->getDb2()->close(); diff --git a/yii2-adapter/legacy/validators/AssetLocationValidator.php b/yii2-adapter/legacy/validators/AssetLocationValidator.php index 5951628112e..3243214b976 100644 --- a/yii2-adapter/legacy/validators/AssetLocationValidator.php +++ b/yii2-adapter/legacy/validators/AssetLocationValidator.php @@ -7,10 +7,10 @@ namespace craft\validators; -use Craft; use craft\helpers\Assets; use craft\helpers\Assets as AssetsHelper; use CraftCms\Cms\Asset\Elements\Asset; +use CraftCms\Cms\Asset\Folders; use CraftCms\Cms\Cms; use yii\base\InvalidConfigException; use yii\base\Model; @@ -110,7 +110,7 @@ public function validateAttribute($model, $attribute): void } // Get the folder - if (Craft::$app->getAssets()->getFolderById($folderId) === null) { + if (app(Folders::class)->getFolderById($folderId) === null) { throw new InvalidConfigException('Invalid folder ID: ' . $folderId); } @@ -124,7 +124,7 @@ public function validateAttribute($model, $attribute): void // Prepare the filename $filename = AssetsHelper::prepareAssetName($filename); - $suggestedFilename = Craft::$app->getAssets()->getNameReplacementInFolder($filename, $folderId); + $suggestedFilename = app(\CraftCms\Cms\Asset\Assets::class)->getNameReplacementInFolder($filename, $folderId); if ($suggestedFilename !== $filename) { $model->{$this->conflictingFilenameAttribute} = $filename; diff --git a/yii2-adapter/legacy/web/assets/cp/CpAsset.php b/yii2-adapter/legacy/web/assets/cp/CpAsset.php index d624ccc4987..3bc7df17ee6 100644 --- a/yii2-adapter/legacy/web/assets/cp/CpAsset.php +++ b/yii2-adapter/legacy/web/assets/cp/CpAsset.php @@ -26,6 +26,7 @@ use craft\web\assets\jquerypayment\JqueryPaymentAsset; use craft\web\assets\jquerytouchevents\JqueryTouchEventsAsset; use craft\web\assets\jqueryui\JqueryUiAsset; +use craft\web\assets\picturefill\PicturefillAsset; use craft\web\assets\selectize\SelectizeAsset; use craft\web\assets\tailwindreset\TailwindResetAsset; use craft\web\assets\theme\ThemeAsset; @@ -57,6 +58,7 @@ use CraftCms\Cms\View\Enums\Position; use CraftCms\Yii2Adapter\Yii2ServiceProvider; use Illuminate\Support\Facades\Auth; +use stdClass; use yii\web\JqueryAsset; use function CraftCms\Cms\t; @@ -91,6 +93,7 @@ class CpAsset extends AssetBundle FabricAsset::class, IframeResizerAsset::class, ThemeAsset::class, + PicturefillAsset::class, ]; /** @@ -200,7 +203,7 @@ private function _craftData(): array 'timepickerOptions' => $this->_timepickerOptions($formattingLocale, $orientation), 'timezone' => app()->getTimezone(), 'tokenParam' => $generalConfig->tokenParam, - 'translations' => I18N::getAllTranslationsForLocale(app()->getLocale()) ?: new \stdClass(), + 'translations' => I18N::getAllTranslationsForLocale(app()->getLocale()) ?: new stdClass(), 'useEmailAsUsername' => $generalConfig->useEmailAsUsername, 'usePathInfo' => $generalConfig->usePathInfo, ];