diff --git a/.gitignore b/.gitignore index 7595791..a010026 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .prettierrc package.json yarn.lock +composer.lock +/public/* +/vendor/* diff --git a/Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php b/Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php index 5d4cac1..020432b 100644 --- a/Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php +++ b/Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php @@ -12,19 +12,14 @@ use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Fluid\View\StandaloneView; -use Visol\Cloudinary\Driver\CloudinaryFastDriver; +use Visol\Cloudinary\Driver\CloudinaryDriver; use Visol\Cloudinary\Services\ConfigurationService; class InlineCloudinaryControlContainer extends InlineControlContainer { - public function render() { - - // We load here the cloudinary library - /** @var AssetCollector $assetCollector */ - $assetCollector = GeneralUtility::makeInstance(AssetCollector::class); - $assetCollector->addJavaScript('media_library_cloudinary', 'https://media-library.cloudinary.com/global/all.js', []); - + public function render() + { /** @var PageRenderer $pageRenderer */ $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); $pageRenderer->loadRequireJsModule('TYPO3/CMS/Cloudinary/CloudinaryMediaLibrary'); @@ -32,30 +27,13 @@ public function render() { return parent::render(); } - /** - * @param array $inlineConfiguration - * @return string - */ - protected function renderPossibleRecordsSelectorTypeGroupDB(array $inlineConfiguration) + protected function renderPossibleRecordsSelectorTypeGroupDB(array $inlineConfiguration): ?string { $typo3Buttons = parent::renderPossibleRecordsSelectorTypeGroupDB($inlineConfiguration); // We could have multiple cloudinary buttons / storages $cloudinaryButtons = $this->renderCloudinaryButtons($inlineConfiguration); - - // Inject button before help-block - if (strpos($typo3Buttons, '
') > 0) { - $typo3Buttons = str_replace( - '
', - $cloudinaryButtons . '
', - $typo3Buttons, - ); - // Try to inject it into the form-control container - } elseif (preg_match('/<\/div><\/div>$/i', $typo3Buttons)) { - $typo3Buttons = preg_replace('/<\/div><\/div>$/i', $cloudinaryButtons . '
', $typo3Buttons); - } else { - $typo3Buttons .= $cloudinaryButtons; - } + $typo3Buttons = $this->appendButtons($typo3Buttons, $cloudinaryButtons); return $typo3Buttons; } @@ -72,7 +50,7 @@ protected function renderCloudinaryButtons(array $inlineConfiguration): string $view = $this->initializeStandaloneView('EXT:cloudinary/Resources/Private/Standalone/MediaLibrary/Show.html'); $view->assignMultiple([ 'objectGroup' => $objectGroup, - 'cloudinaryCredentials' => json_encode($this->computeCloudinaryCredentials($storages)), + 'cloudinaryCredentials' => $this->computeCloudinaryCredentials($storages), ]); return $view->render(); @@ -107,7 +85,7 @@ protected function getCloudinaryStorages(): array $storageItems = $query ->select('*') ->from('sys_file_storage') - ->where($query->expr()->eq('driver', $query->expr()->literal(CloudinaryFastDriver::DRIVER_TYPE))) + ->where($query->expr()->eq('driver', $query->expr()->literal(CloudinaryDriver::DRIVER_TYPE))) ->execute() ->fetchAllAssociativeIndexed(); @@ -159,4 +137,22 @@ protected function computeCloudinaryCredentials(array $storages): array return $cloudinaryCredentials; } + + protected function appendButtons(string $typo3Buttons, string $cloudinaryButtons): ?string + { + // Inject button before help-block + if (strpos($typo3Buttons, '
') > 0) { + $typo3Buttons = str_replace( + '
', + $cloudinaryButtons . '
', + $typo3Buttons, + ); + // Try to inject it into the form-control container + } elseif (preg_match('/<\/div><\/div>$/i', $typo3Buttons)) { + $typo3Buttons = preg_replace('/<\/div><\/div>$/i', $cloudinaryButtons . '
', $typo3Buttons); + } else { + $typo3Buttons .= $cloudinaryButtons; + } + return $typo3Buttons; + } } diff --git a/Classes/Cache/CloudinaryTypo3Cache.php b/Classes/Cache/CloudinaryTypo3Cache.php deleted file mode 100644 index eba3d91..0000000 --- a/Classes/Cache/CloudinaryTypo3Cache.php +++ /dev/null @@ -1,189 +0,0 @@ -storageUid = $storageUid; - } - - /** - * @param string $folderIdentifier - * @return array|false - */ - public function getCachedFiles(string $folderIdentifier) - { - return $this->get($this->computeFileCacheKey($folderIdentifier)); - } - - /** - * @param string $folderIdentifier - * @param array $files - */ - public function setCachedFiles(string $folderIdentifier, array $files): void - { - $this->set($this->computeFileCacheKey($folderIdentifier), $files, self::TAG_FILE); - } - - /** - * @param string $folderIdentifier - * @return array|false - */ - public function getCachedFolders(string $folderIdentifier) - { - return $this->get($this->computeFolderCacheKey($folderIdentifier)); - } - - /** - * @param string $folderIdentifier - * @param array $folders - */ - public function setCachedFolders(string $folderIdentifier, array $folders): void - { - $this->set($this->computeFolderCacheKey($folderIdentifier), $folders, self::TAG_FOLDER); - } - - /** - * @param string $identifier - * @return array|false - */ - protected function get(string $identifier) - { - return $this->isCacheEnabled ? $this->getCacheInstance()->get($identifier) : false; - } - - /** - * @param string $identifier - * @param array $data - * @param string $tag - */ - protected function set(string $identifier, array $data, $tag): void - { - if ($this->isCacheEnabled) { - $this->getCacheInstance()->set($identifier, $data, [$tag], self::LIFETIME); - - $this->log('Caching "%s" data with folder identifier "%s"', [$tag, $identifier]); - } - } - - /** - * @param string $folderIdentifier - * @return mixed - */ - protected function computeFolderCacheKey($folderIdentifier): string - { - // Sanitize the cache format as the key can not contains certain characters such as "/", ":", etc.. - return sprintf('storage-%s-folders-%s', $this->storageUid, str_replace('/', '%', $folderIdentifier)); - } - - /** - * @param string $folderIdentifier - * @return mixed - */ - protected function computeFileCacheKey($folderIdentifier): string - { - // Sanitize the cache format as the key can not contains certain characters such as "/", ":", etc.. - return sprintf('storage-%s-files-%s', $this->storageUid, str_replace('/', '%', $folderIdentifier)); - } - - /** - * @return void - */ - public function flushFileCache(): void - { - $this->getCacheInstance()->flushByTags([self::TAG_FILE]); - $this->log('Method "flushFileCache": file cache flushed'); - } - - /** - * @return void - */ - public function flushFolderCache(): void - { - $this->getCacheInstance()->flushByTags([self::TAG_FOLDER]); - $this->log('Method "flushFolderCache": folder cache flushed'); - } - - /** - * @return void - */ - public function flushAll(): void - { - $this->getCacheInstance()->flush(); - $this->log('Method "flushAll": all cache flushed'); - } - - /** - * @return AbstractFrontend - */ - protected function getCacheInstance() - { - return $this->getCacheManager()->getCache('cloudinary'); - } - - /** - * Return the Cache Manager - * - * @return CacheManager|object - */ - protected function getCacheManager() - { - return GeneralUtility::makeInstance(CacheManager::class); - } - - /** - * @param string $message - * @param array $arguments - */ - public function log(string $message, array $arguments = []) - { - /** @var Logger $logger */ - $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); - #$logger->log( - # LogLevel::INFO, - # vsprintf('[CACHE] ' . $message, $arguments) - #); - } -} diff --git a/Classes/CloudinaryException.php b/Classes/CloudinaryException.php index 6acc908..f91291b 100644 --- a/Classes/CloudinaryException.php +++ b/Classes/CloudinaryException.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary; -class CloudinaryException extends \Exception +use Exception; + +class CloudinaryException extends Exception { } diff --git a/Classes/CloudinaryFactory.php b/Classes/CloudinaryFactory.php index c84992d..2b8f0d7 100644 --- a/Classes/CloudinaryFactory.php +++ b/Classes/CloudinaryFactory.php @@ -9,6 +9,7 @@ namespace Visol\Cloudinary; +use Exception; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Resource\Folder; use TYPO3\CMS\Core\Resource\ResourceFactory; @@ -18,7 +19,7 @@ /** * Class CloudinaryFactory */ -class CloudinaryFactory extends \Exception +class CloudinaryFactory extends Exception { /** * @return ResourceStorage diff --git a/Classes/Command/AbstractCloudinaryCommand.php b/Classes/Command/AbstractCloudinaryCommand.php index 6a309fc..00948f2 100644 --- a/Classes/Command/AbstractCloudinaryCommand.php +++ b/Classes/Command/AbstractCloudinaryCommand.php @@ -29,30 +29,15 @@ abstract class AbstractCloudinaryCommand extends Command const WARNING = 'warning'; const ERROR = 'error'; - /** - * @var SymfonyStyle - */ - protected $io; + protected SymfonyStyle $io; - /** - * @var bool - */ - protected $isSilent = false; + protected bool $isSilent = false; - /** - * @var string - */ - protected $tableName = 'sys_file'; + protected string $tableName = 'sys_file'; - /** - * @param ResourceStorage $storage - * @param InputInterface $input - * - * @return array - */ protected function getFiles(ResourceStorage $storage, InputInterface $input): array { - $query = $this->getQueryBuilder(); + $query = $this->getQueryBuilder($this->tableName); $query ->select('*') ->from($this->tableName) @@ -110,13 +95,9 @@ protected function getFiles(ResourceStorage $storage, InputInterface $input): ar } } - return $query->execute()->fetchAll(); + return $query->execute()->fetchAllAssociative(); } - /** - * @param string $type - * @param array $files - */ protected function writeLog(string $type, array $files) { $logFileName = sprintf( @@ -141,22 +122,15 @@ protected function writeLog(string $type, array $files) ); } - /** - * @param ResourceStorage $storage - * - * @return bool - */ protected function checkDriverType(ResourceStorage $storage): bool { return $storage->getDriverType() === CloudinaryDriver::DRIVER_TYPE; } /** - * @param string $message - * @param array $arguments * @param string $severity can be 'warning', 'error', 'success' */ - protected function log(string $message = '', array $arguments = [], $severity = '') + protected function log(string $message = '', array $arguments = [], string $severity = '') { if (!$this->isSilent) { $formattedMessage = vsprintf($message, $arguments); @@ -168,47 +142,28 @@ protected function log(string $message = '', array $arguments = [], $severity = } } - /** - * @param string $message - * @param array $arguments - */ protected function success(string $message = '', array $arguments = []) { $this->log($message, $arguments, self::SUCCESS); } - /** - * @param string $message - * @param array $arguments - */ protected function warning(string $message = '', array $arguments = []) { $this->log($message, $arguments, self::WARNING); } - /** - * @param string $message - * @param array $arguments - */ protected function error(string $message = '', array $arguments = []) { $this->log($message, $arguments, self::ERROR); } - - /** - * @return object|QueryBuilder - */ - protected function getQueryBuilder(): QueryBuilder + protected function getQueryBuilder(string $tableName): QueryBuilder { /** @var ConnectionPool $connectionPool */ $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); - return $connectionPool->getQueryBuilderForTable($this->tableName); + return $connectionPool->getQueryBuilderForTable($tableName); } - /** - * @return object|Connection - */ protected function getConnection(): Connection { /** @var ConnectionPool $connectionPool */ diff --git a/Classes/Command/CloudinaryAcceptanceTestCommand.php b/Classes/Command/CloudinaryAcceptanceTestCommand.php index ae8cac6..69631ca 100644 --- a/Classes/Command/CloudinaryAcceptanceTestCommand.php +++ b/Classes/Command/CloudinaryAcceptanceTestCommand.php @@ -9,7 +9,9 @@ * LICENSE.md file that was distributed with this source code. */ +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; +use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Database\ConnectionPool; use Visol\Cloudinary\Driver\CloudinaryDriver; use Symfony\Component\Console\Input\InputArgument; @@ -41,15 +43,12 @@ function ($className) { } ); -/** - * Class CloudinaryAcceptanceTestCommand - */ class CloudinaryAcceptanceTestCommand extends AbstractCloudinaryCommand { /** * Configure the command by defining the name, options and arguments */ - protected function configure() + protected function configure(): void { $message = 'Run a suite of Acceptance Tests'; $this @@ -62,54 +61,39 @@ protected function configure() 'The API configuration' ) ->setHelp( - 'Usage: ./vendor/bin/typo3 cloudinary:tests' + 'Usage: ./vendor/bin/typo3 cloudinary:tests bucket-name:my-api-key:my-api-secret' ); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ - protected function initialize(InputInterface $input, OutputInterface $output) + protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); } - /** - * @param InputInterface $input - * @param OutputInterface $output - * - * @return int - */ protected function execute(InputInterface $input, OutputInterface $output): int { // We should dynamically inject the configuration. For now use an existing driver [$couldName, $apiKey, $apiSecret] = GeneralUtility::trimExplode(':', $input->getArgument('api-configuration')); if (!$couldName || !$apiKey || !$apiSecret) { // Everything must be defined! - $message = 'API configuration is incomplete. Format should be "cld-name:1234:abcd".' . LF . LF; - $message .= '"cld-name" is the name of the cloudinary bucket' . LF; - $message .= '"12345" is the API key' . LF; - $message .= '"abcd" is the API secret' . LF . LF; - $message .= 'https://cloudinary.com/console' . LF; - $message .= 'Strong advice! Take a free account to run the test suite'; + $message = 'API configuration is incomplete. Format should be "bucket-name:my-api-key:my-api-secret".'; $this->error($message); - return 1; + return Command::INVALID; } - $this->log('Starting tests...'); + $logFile = Environment::getVarPath() . '/log/cloudinary.log'; $this->log('Hint! Look at the log to get more insight:'); - $this->log('tail -f web/typo3temp/var/logs/cloudinary.log'); + $this->log('tail -f ' . $logFile); $this->log(); // Create a testing storage $storageId = $this->setUp($couldName, $apiKey, $apiSecret); if (!$storageId) { $this->error('Something went wrong. I could not create a testing storage'); - return 2; + return Command::FAILURE; } - // Test case for video file + // Test case for files $testSuite = new FileTestSuite($storageId, $this->io); $testSuite->runTests(); @@ -118,16 +102,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->tearDown($storageId); - return 0; + return Command::SUCCESS; } - /** - * @param string $cloudName - * @param string $apiKey - * @param string $apiSecret - * - * @return int - */ protected function setUp(string $cloudName, string $apiKey, string $apiSecret): int { $values = [ @@ -178,9 +155,6 @@ protected function setUp(string $cloudName, string $apiKey, string $apiSecret): return (int)$db->lastInsertId(); } - /** - * @param int $storageId - */ protected function tearDown(int $storageId) { /** @var ConnectionPool $connectionPool */ @@ -205,7 +179,7 @@ protected function tearDown(int $storageId) ); // Remove all cache - $db->truncate('cf_cloudinary'); - $db->truncate('cf_cloudinary_tags'); +// $db->truncate('cf_cloudinary'); +// $db->truncate('cf_cloudinary_tags'); } } diff --git a/Classes/Command/CloudinaryApiCommand.php b/Classes/Command/CloudinaryApiCommand.php new file mode 100644 index 0000000..d2c2860 --- /dev/null +++ b/Classes/Command/CloudinaryApiCommand.php @@ -0,0 +1,190 @@ +1d" + +# List the resources instead of the whole resource +typo3 cloudinary:api [0-9] --expression="folder=fileadmin/_processed_/*" --list + +# Delete the resources according to the expression +typo3 cloudinary:api [0-9] --expression="folder=fileadmin/_processed_/*" --delete +typo3 cloudinary:api [0-9] --expression="folder=fileadmin/_processed_/*" --delete --force + '; + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + + /** @var ResourceFactory $resourceFactory */ + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $this->storage = $resourceFactory->getStorageObject($input->getArgument('storage')); + } + + protected function configure(): void + { + $message = 'Interact with cloudinary API'; + $this->setDescription($message) + ->addOption('silent', 's', InputOption::VALUE_OPTIONAL, 'Mute output as much as possible', false) + ->addOption('force', 'f', InputOption::VALUE_OPTIONAL, 'Force the given action without further confirmation', false) + ->addOption('fileUid', '', InputOption::VALUE_OPTIONAL, 'File uid', '') + ->addOption('publicId', '', InputOption::VALUE_OPTIONAL, 'Cloudinary public id', '') + ->addOption('type', '', InputOption::VALUE_OPTIONAL, 'In combination with publicId, overrides the type. Possible value iamge, video or raw', 'image') + ->addOption('expression', '', InputOption::VALUE_OPTIONAL, 'Cloudinary search expression e.g --expression="folder=fileadmin/*"', '') + ->addOption('list', '', InputOption::VALUE_OPTIONAL, 'List instead of the whole resource --expression="folder=fileadmin/_processed_/*" --list', false) + ->addOption('delete', '', InputOption::VALUE_OPTIONAL, 'Delete the resources --expression="folder=fileadmin/*" --delete', false) + ->addArgument('storage', InputArgument::OPTIONAL, 'Storage identifier') + ->setHelp($this->help); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->checkDriverType($this->storage)) { + $this->log('Look out! Storage is not of type "cloudinary"'); + return Command::INVALID; + } + + $publicId = $input->getOption('publicId'); + $expression = $input->getOption('expression'); + $list = $input->getOption('list') === null; + $delete = $input->getOption('delete') === null; + $force = $input->getOption('force') === null; + + if ($delete && !$force) { + // ask the user whether it should continue + $continue = $this->io->confirm('Are you sure you want to delete the resources?'); + if (!$continue) { + $this->log('Aborting...'); + return Command::SUCCESS; + } + } + + /** @var int $fileUid */ + $fileUid = $input->getOption('fileUid'); + if ($fileUid) { + /** @var ResourceFactory $resourceFactory */ + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $file = $resourceFactory->getFileObject($fileUid); + + $this->storage = $file->getStorage(); // just to be sure + $publicId = $this->getPublicIdFromFile($file); + } + + try { + if ($publicId) { + $resource = $this->getAdminApi()->asset($publicId, ['resource_type' => $input->getOption('type')]); + $this->log(var_export((array)$resource, true)); + } elseif ($expression) { + + $counter = 0; + do { + $nextCursor = isset($response) + ? $response['next_cursor'] + : ''; + + $response = $this->getSearchApi() + ->expression($expression) + ->sortBy('public_id', 'asc') + ->maxResults(100) + ->nextCursor($nextCursor) + ->execute(); + + if (is_array($response['resources'])) { + $_resources = []; + foreach ($response['resources'] as $resource) { + if ($list || $delete) { + $this->log($resource['public_id']); + } else { + $this->log(var_export((array)$resource, true)); + } + + // collect resources in case of deletion. + $_resources[] = $resource['public_id']; + } + // delete the resource if told + if ($delete) { + $counter++; + $this->log("\nDeleting batch #$counter...\n"); + $this->getAdminApi()->deleteAssets($_resources); + } + } + } while (!empty($response) && isset($response['next_cursor'])); + + } else { + $this->log('Nothing to do...'); + } + } catch (Exception $exception) { + // Triggered when no resources are found when deleting files. + if ($exception->getMessage() === 'Missing required parameter - public_ids') { + $this->log("No resources found for expression '$expression'. Nothing to do..."); + return Command::SUCCESS; + } + $this->error($exception->getMessage()); + } + + return Command::SUCCESS; + } + + protected function getPublicIdFromFile(File $file): string + { + /** @var CloudinaryPathService $cloudinaryPathService */ + $cloudinaryPathService = GeneralUtility::makeInstance( + CloudinaryPathService::class, + $file->getStorage(), + ); + return $cloudinaryPathService->computeCloudinaryPublicId($file->getIdentifier()); + } + + protected function getSearchApi(): SearchApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->searchApi(); + } + + protected function getAdminApi(): AdminApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->adminApi(); + } + +} diff --git a/Classes/Command/CloudinaryCopyCommand.php b/Classes/Command/CloudinaryCopyCommand.php index dba3ca2..f9b5b2e 100644 --- a/Classes/Command/CloudinaryCopyCommand.php +++ b/Classes/Command/CloudinaryCopyCommand.php @@ -9,6 +9,7 @@ * LICENSE.md file that was distributed with this source code. */ +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -20,31 +21,15 @@ use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; -/** - * Class CloudinaryCopyCommand - */ class CloudinaryCopyCommand extends AbstractCloudinaryCommand { - /** - * @var array - */ - protected $missingFiles = []; + protected array $missingFiles = []; - /** - * @var ResourceStorage - */ - protected $sourceStorage; + protected ResourceStorage $sourceStorage; - /** - * @var ResourceStorage - */ - protected $targetStorage; + protected ResourceStorage $targetStorage; - /** - * @param InputInterface $input - * @param OutputInterface $output - */ - protected function initialize(InputInterface $input, OutputInterface $output) + protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); @@ -59,7 +44,7 @@ protected function initialize(InputInterface $input, OutputInterface $output) /** * Configure the command by defining the name, options and arguments */ - protected function configure() + protected function configure(): void { $this->setDescription('Copy bunch of images from a local storage to a cloudinary storage') ->addOption('silent', 's', InputOption::VALUE_OPTIONAL, 'Mute output as much as possible', false) @@ -74,22 +59,18 @@ protected function configure() ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:copy 1 2'); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->checkDriverType($this->targetStorage)) { $this->log('Look out! target storage is not of type "cloudinary"'); - return 1; + return Command::INVALID; } $files = $this->getFiles($this->sourceStorage, $input); if (count($files) === 0) { $this->log('No files found, no work for me!'); - return 0; + return Command::SUCCESS; } $this->log('Copying %s files from storage "%s" (%s) to "%s" (%s)', [ @@ -106,7 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$response) { $this->log('Script aborted'); - return 0; + return Command::SUCCESS; } } @@ -148,28 +129,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int print_r($this->missingFiles); } - return 0; + return Command::SUCCESS; } - /** - * @param File $fileObject - * @param string $url - * - * @return bool - */ public function download(File $fileObject, string $url): bool { $this->ensureDirectoryExistence($fileObject); $contents = file_get_contents($url); - return $contents ? (bool) file_put_contents($this->getAbsolutePath($fileObject), $contents) : false; + return $contents ? (bool)file_put_contents($this->getAbsolutePath($fileObject), $contents) : false; } - /** - * @param File $fileObject - * - * @return string - */ protected function getAbsolutePath(File $fileObject): string { // Compute the absolute file name of the file to move @@ -178,9 +148,6 @@ protected function getAbsolutePath(File $fileObject): string return GeneralUtility::getFileAbsFileName($fileRelativePath); } - /** - * @param File $fileObject - */ protected function ensureDirectoryExistence(File $fileObject) { // Make sure the directory exists diff --git a/Classes/Command/CloudinaryFixImageDimensionCommand.php b/Classes/Command/CloudinaryFixImageDimensionCommand.php new file mode 100644 index 0000000..0a78bbf --- /dev/null +++ b/Classes/Command/CloudinaryFixImageDimensionCommand.php @@ -0,0 +1,146 @@ +io = new SymfonyStyle($input, $output); + + $this->isSilent = $input->getOption('silent'); + + /** @var ResourceFactory $resourceFactory */ + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $this->storage = $resourceFactory->getStorageObject($input->getArgument('target')); + + $this->cloudinaryResourceService = GeneralUtility::makeInstance( + CloudinaryResourceService::class, + $this->storage, + ); + + $this->cloudinaryPathService = GeneralUtility::makeInstance( + CloudinaryPathService::class, + $this->storage, + ); + + $this->metadataRepository = GeneralUtility::makeInstance(MetaDataRepository::class); + } + + protected function configure(): void + { + $this->setDescription('Fix missing width and height of image.') + ->addOption('silent', 's', InputOption::VALUE_OPTIONAL, 'Mute output as much as possible', false) + ->addOption('yes', 'y', InputOption::VALUE_OPTIONAL, 'Accept everything by default', false) + ->addArgument('target', InputArgument::REQUIRED, 'Target storage identifier') + ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:fix:dimension [0-9]'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->checkDriverType($this->storage)) { + $this->log('Look out! target storage is not of type "cloudinary"'); + return Command::INVALID; + } + + $files = $this->getProblematicImages(); + + foreach ($files as $file) { + $fileObject = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($file['uid']); + if ($fileObject->exists()) { + $this->log('Fixing %s (%s)', [$fileObject->getIdentifier(), $fileObject->getUid()]); + + // get the corresponding cloudinary resource + $publicId = $this->cloudinaryPathService->computeCloudinaryPublicId($fileObject->getIdentifier()); + + $cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId); + + // we update the metadata + $this->metadataRepository->update( + $fileObject->getUid(), + [ + 'width' => $cloudinaryResource['width'], + 'height' => $cloudinaryResource['height'], + ] + ); + + } + } + + return Command::SUCCESS; + } + + protected function getProblematicImages(): array + { + $query = $this->getQueryBuilder($this->tableName); + $query + ->select('sys_file.uid') + ->from($this->tableName) + ->join( + $this->tableName, + 'sys_file_metadata', + 'metadata', + $query->expr()->eq( + 'metadata.file', + $this->tableName . '.uid' + ) + ) + ->where( + $query->expr()->eq( + 'storage', + $this->storage->getUid() + ), + $query->expr()->eq( + 'type', + File::FILETYPE_IMAGE + ), + $query->expr()->eq( + 'missing', + 0 + ), + $query->expr()->orX( + $query->expr()->eq( + 'metadata.width', + 0 + ), + $query->expr()->eq( + 'metadata.height', + 0 + ) + ) + ); + + return $query->execute()->fetchAllAssociative(); + } +} diff --git a/Classes/Command/CloudinaryFixJpegCommand.php b/Classes/Command/CloudinaryFixJpegCommand.php index 2b5f1fb..af79fd0 100644 --- a/Classes/Command/CloudinaryFixJpegCommand.php +++ b/Classes/Command/CloudinaryFixJpegCommand.php @@ -9,6 +9,7 @@ * LICENSE.md file that was distributed with this source code. */ +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -18,21 +19,13 @@ use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; -/** - * Class CloudinaryFixJpegCommand - */ class CloudinaryFixJpegCommand extends AbstractCloudinaryCommand { - /** - * @var ResourceStorage - */ - protected $targetStorage; - - /** - * @param InputInterface $input - * @param OutputInterface $output - */ - protected function initialize(InputInterface $input, OutputInterface $output) + protected ResourceStorage $targetStorage; + + protected string $tableName = 'sys_file'; + + protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); @@ -43,39 +36,28 @@ protected function initialize(InputInterface $input, OutputInterface $output) $this->targetStorage = $resourceFactory->getStorageObject($input->getArgument('target')); } - /** - * Configure the command by defining the name, options and arguments - */ - protected function configure() + protected function configure(): void { $message = 'After "moving" files you should fix the jpeg extension. Consult README.md for more info.'; $this->setDescription($message) ->addOption('silent', 's', InputOption::VALUE_OPTIONAL, 'Mute output as much as possible', false) ->addOption('yes', 'y', InputOption::VALUE_OPTIONAL, 'Accept everything by default', false) ->addArgument('target', InputArgument::REQUIRED, 'Target storage identifier') - ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:fix [0-9]'); + ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:fix:image:after-move [0-9]'); } - /** - * Move file - * - * @param InputInterface $input - * @param OutputInterface $output - * - * @return int - */ protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->checkDriverType($this->targetStorage)) { $this->log('Look out! target storage is not of type "cloudinary"'); - return 1; + return Command::INVALID; } $files = $this->getJpegFiles(); if (count($files) === 0) { $this->log('No files found, no work for me!'); - return 0; + return Command::SUCCESS; } $this->log('I will update %s files by replacing "jpeg" to "jpg" in various fields in storage "%s" (%s)', [ @@ -90,7 +72,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$response) { $this->log('Script aborted'); - return 0; + return Command::SUCCESS; } } @@ -106,20 +88,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $connection->query($query)->execute(); - return 0; + + return Command::SUCCESS; } - /** - * @return array - */ protected function getJpegFiles(): array { - $query = $this->getQueryBuilder(); + $query = $this->getQueryBuilder($this->tableName); $query ->select('*') ->from($this->tableName) ->where($query->expr()->eq('storage', $this->targetStorage->getUid()), $query->expr()->eq('extension', $query->expr()->literal('jpeg'))); - return $query->execute()->fetchAll(); + return $query->execute()->fetchAllAssociative(); } } diff --git a/Classes/Command/CloudinaryMetadataCommand.php b/Classes/Command/CloudinaryMetadataCommand.php new file mode 100644 index 0000000..c29d755 --- /dev/null +++ b/Classes/Command/CloudinaryMetadataCommand.php @@ -0,0 +1,122 @@ +io = new SymfonyStyle($input, $output); + + /** @var ResourceFactory $resourceFactory */ + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $this->storage = $resourceFactory->getStorageObject($input->getArgument('storage')); + + $this->cloudinaryPathService = GeneralUtility::makeInstance( + CloudinaryPathService::class, + $this->storage, + ); + } + + protected function configure(): void + { + $message = 'Set metadata on cloudinary resources such as file reference and file usage.'; + $this->setDescription($message) + ->addOption('silent', 's', InputOption::VALUE_OPTIONAL, 'Mute output as much as possible', false) + ->addArgument('storage', InputArgument::REQUIRED, 'Storage identifier') + ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:metadata [0-9]'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->checkDriverType($this->storage)) { + $this->log('Look out! Storage is not of type "cloudinary"'); + return Command::INVALID; + } + + $q = $this->getQueryBuilder('sys_file'); + $items = $q->select('file.*', 'reference.*') + ->from('sys_file', 'file') + ->innerJoin( + 'file', + 'sys_file_reference', + 'reference', + 'file.uid = reference.uid_local' + ) + ->where( + $q->expr()->eq('file.storage', $this->storage->getUid()), + $q->expr()->or( + // we could extend to more tables... + $q->expr()->eq('tablenames', $q->expr()->literal('tt_content')), + $q->expr()->eq('tablenames', $q->expr()->literal('pages')) + ) + ) + ->execute() + ->fetchAllAssociative(); + + $site = $this->getFirstSite(); + + $publicIdOptions = []; + foreach ($items as $item) { + $publicId = $this->cloudinaryPathService->computeCloudinaryPublicId($item['identifier']); + $publicIdOptions[$publicId]['tags'][$item['pid']] = 't3-page-' . $item['pid']; + $publicIdOptions[$publicId]['context']['t3-page-' . $item['pid']] = rtrim((string)$site->getBase(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '?id=' . $item['pid']; + } + + // Initialize and configure the API + foreach ($publicIdOptions as $publicId => $options) { + $this->log('Updating tags and metadata for public id ' . $publicId); + $this->getUploadApi()->explicit( + $publicId, + [ + 'type' => 'upload', + 'tags' => 't3,t3-page,' . implode(', ', $options['tags']), + 'context' => $options['context'] + ] + ); + } + + return Command::SUCCESS; + } + + public function getFirstSite(): Site + { + $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + $sites = $siteFinder->getAllSites(); + return array_values($sites)[0]; + } + + protected function getUploadApi(): UploadApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->uploadApi(); + } + +} diff --git a/Classes/Command/CloudinaryMoveCommand.php b/Classes/Command/CloudinaryMoveCommand.php index bea50ea..a5bb353 100644 --- a/Classes/Command/CloudinaryMoveCommand.php +++ b/Classes/Command/CloudinaryMoveCommand.php @@ -10,6 +10,7 @@ */ use Exception; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\PathUtility; @@ -22,40 +23,22 @@ use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; -/** - * Class CloudinaryMoveCommand - */ class CloudinaryMoveCommand extends AbstractCloudinaryCommand { - /** - * @var array - */ - protected $faultyUploadedFiles; + protected array $faultyUploadedFiles; - /** - * @var array - */ - protected $skippedFiles; + protected array $skippedFiles; - /** - * @var array - */ - protected $missingFiles = []; + protected array $missingFiles = []; - /** - * @var ResourceStorage - */ - protected $sourceStorage; + protected ResourceStorage $sourceStorage; - /** - * @var ResourceStorage - */ - protected $targetStorage; + protected ResourceStorage $targetStorage; /** * Configure the command by defining the name, options and arguments */ - protected function configure() + protected function configure(): void { $message = 'Move bunch of images to a cloudinary storage. Consult the README.md for more info.'; $this->setDescription($message) @@ -71,11 +54,7 @@ protected function configure() ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:move 1 2'); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ - protected function initialize(InputInterface $input, OutputInterface $output) + protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); @@ -88,26 +67,18 @@ protected function initialize(InputInterface $input, OutputInterface $output) $this->targetStorage = $resourceFactory->getStorageObject($input->getArgument('target')); } - /** - * Move file - * - * @param InputInterface $input - * @param OutputInterface $output - * - * @return int - */ protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->checkDriverType($this->targetStorage)) { $this->log('Look out! target storage is not of type "cloudinary"'); - return 1; + return Command::INVALID; } $files = $this->getFiles($this->sourceStorage, $input); if (count($files) === 0) { $this->log('No files found, no work for me!'); - return 0; + return Command::SUCCESS; } $this->log('I will process %s files to be moved from storage "%s" (%s) to "%s" (%s)', [ @@ -124,7 +95,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$response) { $this->log('Script aborted'); - return 0; + + return Command::SUCCESS; } } @@ -194,14 +166,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->writeLog('skipped', $this->skippedFiles); } - return 0; + return Command::SUCCESS; } - /** - * @param File $fileObject - * - * @return bool - */ protected function isFileSkipped(File $fileObject): bool { $isDisallowedPath = false; @@ -219,35 +186,23 @@ protected function isFileSkipped(File $fileObject): bool $isDisallowedPath; } - /** - * @return array - */ protected function getDisallowedExtensions(): array { // Empty for now return []; } - /** - * @return array - */ protected function getDisallowedFileIdentifiers(): array { // Empty for now return []; } - /** - * @return array - */ protected function getDisallowedPaths(): array { return ['user_upload/_temp_/', '_temp_/', '_processed_/']; } - /** - * @return object|FileMoveService - */ protected function getFileMoveService(): FileMoveService { return GeneralUtility::makeInstance(FileMoveService::class); diff --git a/Classes/Command/CloudinaryQueryCommand.php b/Classes/Command/CloudinaryQueryCommand.php index 964676f..7841cab 100644 --- a/Classes/Command/CloudinaryQueryCommand.php +++ b/Classes/Command/CloudinaryQueryCommand.php @@ -9,6 +9,7 @@ * LICENSE.md file that was distributed with this source code. */ +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -21,40 +22,11 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use Visol\Cloudinary\Filters\RegularExpressionFilter; -/** - * Examples: - * - * ./vendor/bin/typo3 cloudinary:query 2 - * - * # List of files withing a folder - * ./vendor/bin/typo3 cloudinary:query 2 --path=/foo/ - * - * # List of files withing a folder with recursive flag - * ./vendor/bin/typo3 cloudinary:query 2 --path=/foo/ --recursive - * - * # List of files withing a folder with filter flag - * ./vendor/bin/typo3 cloudinary:query 2 --path=/foo/ --filter='[0-9,a-z]\.jpg' - * - * # Count files / folder - * ./vendor/bin/typo3 cloudinary:query 2 --count - * - * # List of folders instead of files - * ./vendor/bin/typo3 cloudinary:query 2 --folder - * - * Class CloudinaryQueryCommand - */ class CloudinaryQueryCommand extends AbstractCloudinaryCommand { - /** - * @var ResourceStorage - */ - protected $storage; + protected ResourceStorage $storage; - /** - * @param InputInterface $input - * @param OutputInterface $output - */ - protected function initialize(InputInterface $input, OutputInterface $output) + protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); @@ -64,10 +36,31 @@ protected function initialize(InputInterface $input, OutputInterface $output) $this->storage = $resourceFactory->getStorageObject($input->getArgument('storage')); } + protected string $help = ' +Usage: ./vendor/bin/typo3 cloudinary:query [0-9 - storage id] + +Examples + +# List of files withing a folder +typo3 cloudinary:query [0-9] --path=/foo/ + +# List of files withing a folder with recursive flag +typo3 cloudinary:query [0-9] --path=/foo/ --recursive + +# List of files withing a folder with filter flag +typo3 cloudinary:query [0-9] --path=/foo/ --filter=\'[0-9,a-z]\.jpg\' + + # Count files / folder +typo3 cloudinary:query [0-9] --count + + # List of folders instead of files +typo3 cloudinary:query [0-9] --folder + ' ; + /** * Configure the command by defining the name, options and arguments */ - protected function configure() + protected function configure(): void { $message = 'Query a given storage such a list, count files or folders'; $this->setDescription($message) @@ -79,18 +72,14 @@ protected function configure() ->addOption('recursive', 'r', InputOption::VALUE_NONE, 'Recursive lookup') ->addOption('delete', 'd', InputOption::VALUE_NONE, 'Delete found files / folders.') ->addArgument('storage', InputArgument::REQUIRED, 'Storage identifier') - ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:query [0-9]'); + ->setHelp($this->help); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->checkDriverType($this->storage)) { $this->log('Look out! Storage is not of type "cloudinary"'); - return 1; + return Command::INVALID; } // Get the chance to define a filter @@ -141,14 +130,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - return 0; + return Command::SUCCESS; } - /** - * @param InputInterface $input - * - * @return array - */ protected function listFoldersAction(InputInterface $input): array { $folders = $this->storage->getFoldersInFolder($this->getFolder($input->getOption('path')), 0, 0, true, $input->getOption('recursive')); @@ -160,11 +144,6 @@ protected function listFoldersAction(InputInterface $input): array return $folders; } - /** - * @param InputInterface $input - * - * @return array - */ protected function listFilesAction(InputInterface $input): array { $files = $this->storage->getFilesInFolder($this->getFolder($input->getOption('path')), 0, 0, true, $input->getOption('recursive')); @@ -175,11 +154,6 @@ protected function listFilesAction(InputInterface $input): array return $files; } - /** - * @param InputInterface $input - * - * @return void - */ protected function countFoldersAction(InputInterface $input): void { $numberOfFolders = $this->storage->countFoldersInFolder($this->getFolder($input->getOption('path')), true, $input->getOption('recursive')); @@ -187,11 +161,6 @@ protected function countFoldersAction(InputInterface $input): void $this->log('I found %s folder(s)', [$numberOfFolders]); } - /** - * @param InputInterface $input - * - * @return void - */ protected function countFilesAction(InputInterface $input): void { $numberOfFiles = $this->storage->countFilesInFolder($this->getFolder($input->getOption('path')), true, $input->getOption('recursive')); @@ -199,12 +168,7 @@ protected function countFilesAction(InputInterface $input): void $this->log('I found %s files(s)', [$numberOfFiles]); } - /** - * @param string $folderIdentifier - * - * @return object|Folder - */ - protected function getFolder($folderIdentifier): Folder + protected function getFolder(string $folderIdentifier): Folder { $folderIdentifier = $folderIdentifier === DIRECTORY_SEPARATOR ? $folderIdentifier : DIRECTORY_SEPARATOR . trim($folderIdentifier, '/') . DIRECTORY_SEPARATOR; diff --git a/Classes/Command/CloudinaryScanCommand.php b/Classes/Command/CloudinaryScanCommand.php index 41abbc1..f11efb2 100644 --- a/Classes/Command/CloudinaryScanCommand.php +++ b/Classes/Command/CloudinaryScanCommand.php @@ -9,31 +9,42 @@ * LICENSE.md file that was distributed with this source code. */ +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; use Visol\Cloudinary\Services\CloudinaryScanService; -/** - * Class CloudinaryScanCommand - */ class CloudinaryScanCommand extends AbstractCloudinaryCommand { - /** - * @var ResourceStorage - */ protected ResourceStorage $storage; - /** - * @param InputInterface $input - * @param OutputInterface $output - */ - protected function initialize(InputInterface $input, OutputInterface $output) + protected string $help = ' +Usage: ./vendor/bin/typo3 cloudinary:scan [0-9] + +Examples: + +# Query by public id +typo3 cloudinary:scan + +# Query with an additional expression +typo3 cloudinary:scan --expression="folder=fileadmin/* AND NOT folder:fileadmin/_processed_/*" + +Notice: + +You can search for an exact folder path with "folder=fileadmin/*" +or you can search for a folder prefix with "folder:fileadmin/*" +@see https://cloudinary.com/documentation/search_api + ' ; + + + protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); @@ -42,48 +53,45 @@ protected function initialize(InputInterface $input, OutputInterface $output) $this->storage = $resourceFactory->getStorageObject($input->getArgument('storage')); } - /** - * Configure the command by defining the name, options and arguments - */ - protected function configure() + protected function configure(): void { $message = 'Scan and warm up a cloudinary storage.'; $this->setDescription($message) ->addOption('silent', 's', InputOption::VALUE_OPTIONAL, 'Mute output as much as possible', false) ->addOption( - 'empty', - 'e', + 'expression', + '', InputOption::VALUE_OPTIONAL, - 'Before scanning empty all resources for a given storage', - false, + 'Expression used by the cloudinary search api (e.g --expression="folder=fileadmin/* AND NOT folder=fileadmin/_processed_/*', + false ) ->addArgument('storage', InputArgument::REQUIRED, 'Storage identifier') - ->setHelp('Usage: ./vendor/bin/typo3 cloudinary:scan [0-9]'); + ->setHelp($this->help); } protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->checkDriverType($this->storage)) { $this->log('Look out! Storage is not of type "cloudinary"'); - return 1; - } - - if ($input->getOption('empty') === null || $input->getOption('empty')) { - $this->log('Emptying all mirrored resources for storage "%s"', [$this->storage->getUid()]); - $this->log(); - $this->getCloudinaryScanService()->empty(); + return Command::INVALID; } + $logFile = Environment::getVarPath() . '/log/cloudinary.log'; $this->log('Hint! Look at the log to get more insight:'); - $this->log('tail -f web/typo3temp/var/logs/cloudinary.log'); + $this->log('tail -f ' . $logFile); $this->log(); - $result = $this->getCloudinaryScanService()->scan(); + /** @var string $expression */ + $expression = $input->getOption('expression'); + + $result = $this->getCloudinaryScanService() + ->setAdditionalExpression($expression) + ->scan(); $numberOfFiles = $result['created'] + $result['updated'] - $result['deleted']; if ($numberOfFiles !== $result['total']) { - $this->error( - 'Something went wrong. There is a problem with the number of files counted. %s !== %s. It should be fixed in the next scan', + $this->warning( + 'There is a problem with the number of files counted. %s !== %s. It should be fixed in the next scan', [$numberOfFiles, $result['total']], ); } @@ -99,7 +107,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $result['folder_deleted'], ]); - return 0; + return Command::SUCCESS; } protected function getCloudinaryScanService(): CloudinaryScanService diff --git a/Classes/Command/CloudinaryStorageListCommand.php b/Classes/Command/CloudinaryStorageListCommand.php new file mode 100644 index 0000000..2ba9b58 --- /dev/null +++ b/Classes/Command/CloudinaryStorageListCommand.php @@ -0,0 +1,102 @@ +io = new SymfonyStyle($input, $output); + + } + + protected function configure(): void + { + $message = 'Display all available storages configured for cloudinary'; + $this->setDescription($message) + ->setHelp($this->help); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + + $cloudinaryRecords = $this->getCloudinaryRecords(); + foreach ($cloudinaryRecords as $cloudinaryRecord) { + + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $storage = $resourceFactory->getStorageObject($cloudinaryRecord['uid']); + + $this->log('---'); + $this->log(sprintf('uid: %s', $storage->getUid())); + $this->log(sprintf('name: %s' , $storage->getName())); + $configuration = CloudinaryApiUtility::getArrayConfiguration($storage); + foreach ($configuration as $key => $value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + if ($key === 'secure_distribution') { + $key .= ' (cname)'; + } + $this->log(sprintf('%s: %s', $key, $value)); + } + } + + + return Command::SUCCESS; + } + + /** + * We retrieve all cloudinary storages + * + * @return array> + */ + protected function getCloudinaryRecords(): array + { + $q = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_storage'); + + $q->select('*') + ->from('sys_file_storage') + ->where( + $q->expr()->eq('driver', $q->createNamedParameter(CloudinaryDriver::DRIVER_TYPE)) + ); + + return $q->execute()->fetchAllAssociative(); + } + +} diff --git a/Classes/Controller/CloudinaryAjaxController.php b/Classes/Controller/CloudinaryAjaxController.php index 104a976..0d94afb 100644 --- a/Classes/Controller/CloudinaryAjaxController.php +++ b/Classes/Controller/CloudinaryAjaxController.php @@ -2,23 +2,24 @@ namespace Visol\Cloudinary\Controller; -use Cloudinary; +use Cloudinary\Api\Search\SearchApi; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use RuntimeException; use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; use Visol\Cloudinary\Services\CloudinaryPathService; use Visol\Cloudinary\Services\CloudinaryResourceService; -use Visol\Cloudinary\Services\ConfigurationService; +use Visol\Cloudinary\Utility\CloudinaryApiUtility; class CloudinaryAjaxController { public function addFilesAction(ServerRequestInterface $request): ResponseInterface { - $storageUid = (int) $request->getParsedBody()['storageUid']; - $cloudinaryIds = (array) $request->getParsedBody()['cloudinaryIds']; + $storageUid = (int)$request->getParsedBody()['storageUid']; + $cloudinaryIds = (array)$request->getParsedBody()['cloudinaryIds']; /** @var ResourceFactory $resourceFactory */ $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); @@ -29,7 +30,6 @@ public function addFilesAction(ServerRequestInterface $request): ResponseInterfa try { // Initialize objects $storage = $resourceFactory->getStorageObject($storageUid); - $this->initializeApi($storage); $cloudinaryPathService = GeneralUtility::makeInstance( CloudinaryPathService::class, $storage->getConfiguration(), @@ -39,13 +39,13 @@ public function addFilesAction(ServerRequestInterface $request): ResponseInterfa foreach ($cloudinaryIds as $publicId) { // We must retrieve the resources so that we can determine the format - $search = new \Cloudinary\Search(); - $search->expression('public_id:' . $publicId); - $search->max_results(1); - $response = $search->execute(); + $response = $this->getSearchApi($storage) + ->expression('public_id:' . $publicId) + ->maxResults(1) + ->execute(); if (empty($response['resources'])) { - throw new \RuntimeException('Missing resources ' . $publicId, 1657125439); + throw new RuntimeException('Missing resources ' . $publicId, 1657125439); } else { $resource = $response['resources'][0]; } @@ -57,13 +57,13 @@ public function addFilesAction(ServerRequestInterface $request): ResponseInterfa //$cloudinaryPathService->computeFileIdentifier((array) $resource); // Save mirrored file - $cloudinaryResourceService->save((array) $resource); + $cloudinaryResourceService->save((array)$resource); // This will trigger a file indexation $files[] = $storage->getFile($identifier)->getUid(); } } - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { $result = 'ko'; $possibleError = $e->getMessage(); } @@ -73,16 +73,9 @@ public function addFilesAction(ServerRequestInterface $request): ResponseInterfa return $response; } - protected function initializeApi(ResourceStorage $storage): void + protected function getSearchApi(ResourceStorage $storage): SearchApi { - $configurationService = GeneralUtility::makeInstance(ConfigurationService::class, $storage->getConfiguration()); - - Cloudinary::config([ - 'cloud_name' => $configurationService->get('cloudName'), - 'api_key' => $configurationService->get('apiKey'), - 'api_secret' => $configurationService->get('apiSecret'), - 'timeout' => $configurationService->get('timeout'), - 'secure' => true, - ]); + return CloudinaryApiUtility::getCloudinary($storage)->searchApi(); } + } diff --git a/Classes/Controller/CloudinaryWebHookController.php b/Classes/Controller/CloudinaryWebHookController.php new file mode 100644 index 0000000..ca759c3 --- /dev/null +++ b/Classes/Controller/CloudinaryWebHookController.php @@ -0,0 +1,438 @@ +checkEnvironment(); + + /** @var ResourceFactory $resourceFactory */ + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + + $storage = $resourceFactory->getStorageObject((int)$this->settings['storage']); + + $this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + + $this->cloudinaryResourceService = GeneralUtility::makeInstance( + CloudinaryResourceService::class, + $storage, + ); + + $this->scanService = GeneralUtility::makeInstance( + CloudinaryScanService::class, + $storage + ); + + $this->cloudinaryPathService = GeneralUtility::makeInstance( + CloudinaryPathService::class, + $storage + ); + + $this->storage = $storage; + + $this->processedFileRepository = GeneralUtility::makeInstance(ProcessedFileRepository::class); + + $this->packageManager = GeneralUtility::makeInstance(PackageManager::class); + } + + public function processAction(): ResponseInterface + { + $parsedBody = (string)file_get_contents('php://input'); + $payload = (array)json_decode($parsedBody, true); + self::getLogger()->debug($parsedBody); + + if ($this->shouldStopProcessing($payload)) { + return $this->sendResponse(['result' => true, 'message' => 'Nothing to do...']); + } + + + try { + [$requestType, $publicIds] = $this->getRequestInfo($payload); + $clearCachePages = []; + + self::getLogger()->debug(sprintf('Start flushing cache for file action "%s". ', $requestType)); + + foreach ($publicIds as $publicId) { + if ($this->isRequestUploadOverwrite($payload)) { + // Update caches + $this->explicitDataCacheRepository->delete($this->storage->getUid(), $publicId); + + $cloudinaryResource = (array)$this->getAdminApi()->asset($publicId); + $this->cloudinaryResourceService->save($cloudinaryResource); + } + elseif ($requestType === self::NOTIFICATION_TYPE_DELETE) { + if (str_contains($publicId, '_processed_')) { + $message = 'Processed file deleted. Nothing to do, stopping here...'; + } else { + $message = sprintf('Deleted file "%s", this should not happen. A file is going to be missing.', $publicId); + self::getLogger()->warning($message); + } + + // early return + return $this->sendResponse(['result' => true, 'message' => $message]); + + } elseif ($requestType === self::NOTIFICATION_TYPE_RENAME) { // #. handle file rename + + // Delete the old cache resource + $this->cloudinaryResourceService->delete($publicId); + + // Fetch the new cloudinary resource + /** @var string $nextPublicId */ + $nextPublicId = $payload['to_public_id']; + $previousCloudinaryResource = $cloudinaryResource = $this->getCloudinaryResource($nextPublicId); + + $previousCloudinaryResource['public_id'] = $publicId; + $previousFileIdentifier = $this->cloudinaryPathService->computeFileIdentifier($previousCloudinaryResource); + $nextFileIdentifier = $this->cloudinaryPathService->computeFileIdentifier($cloudinaryResource); + + $this->handleFileRename($previousFileIdentifier, $nextFileIdentifier); + } else { + $cloudinaryResource = $this->getCloudinaryResource($publicId); + + // #. flush cloudinary cdn cache only for valid publicId + $this->flushCloudinaryCdn($publicId); + } + + // #. retrieve the source file + $file = $this->getFile($cloudinaryResource); + + // #. flush the process files + $this->clearProcessedFiles($file); + + // #. clean up local temporary file - var/variant folder + $this->cleanUpTemporaryFile($file); + + // #. flush cache pages + $clearCachePages = $this->clearCachePages($file); + } + } catch (\Exception $e) { + return $this->sendResponse([ + 'result' => false, + 'message' => $e->getMessage(), + ]); + } + + $message = $clearCachePages + ? 'Success! Cache flushed for pages ' . implode(',', $clearCachePages) + : 'Success! Job done'; + return $this->sendResponse(['result' => true, 'message' => $message]); + } + + protected function flushCloudinaryCdn(string $publicId): void + { + // Invalidate CDN cache + $this->getUploadApi()->explicit( + $publicId, + [ + 'type' => 'upload', + 'invalidate' => true + ] + ); + } + + protected function handleFileRename(string $previousFileIdentifier, string $nextFileIdentifier): void + { + $nextFolderIdentifier = PathUtility::dirname($nextFileIdentifier); + $nextFolderIdentifierHash = sha1($this->canonicalizeAndCheckFolderIdentifier($nextFolderIdentifier)); + $nextFileIdentifierHash = sha1($this->canonicalizeAndCheckFileIdentifier($nextFileIdentifier)); + $nextName = PathUtility::basename($nextFileIdentifier); + + $tableName = 'sys_file'; + $q = $this->getQueryBuilder($tableName); + $q->update($tableName) + ->where( + $q->expr()->eq('storage', $this->storage->getUid()), + $q->expr()->eq('identifier', $q->expr()->literal($previousFileIdentifier)) + ) + ->set('identifier', $q->expr()->literal($nextFileIdentifier), false) + ->set('identifier_hash', $q->expr()->literal($nextFileIdentifierHash), false) + ->set('folder_hash', $q->expr()->literal($nextFolderIdentifierHash), false) + ->set('name', $q->expr()->literal($nextName), false) + ->setMaxResults(1) + ->executeStatement(); + } + + protected function getFile(array $cloudinaryResource): File + { + $fileIdentifier = $this->cloudinaryPathService->computeFileIdentifier($cloudinaryResource); + $fileIdentifierHash = $this->storage->hashFileIdentifier($fileIdentifier); + $tableName = 'sys_file'; + $q = $this->getQueryBuilder($tableName); + $fileRecord = $q->select('*') + ->from($tableName) + ->where( + $q->expr()->eq('storage', $this->storage->getUid()), + $q->expr()->eq('identifier_hash', $q->expr()->literal($fileIdentifierHash)) + ) + ->execute() + ->fetchAssociative(); + + if (!$fileRecord) { + throw new Exception('No indexed file could be fine for public id ' . $cloudinaryResource['public_id']); + } + + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + return $resourceFactory->getFileObject($fileRecord['uid']); + } + + protected function getRequestInfo(array $payload): array + { + if ($this->isRequestUploadOverwrite($payload)) { + $requestType = self::NOTIFICATION_TYPE_UPLOAD; + $publicIds = [$payload['public_id']]; + } elseif ($this->isRequestRename($payload)) { + $requestType = self::NOTIFICATION_TYPE_RENAME; + $publicIds = [$payload['from_public_id']]; + } elseif ($this->isRequestDelete($payload)) { + $requestType = self::NOTIFICATION_TYPE_DELETE; + $publicIds = []; + foreach ($payload['resources'] as $resource) { + $publicIds[] = $resource['public_id']; + } + } else { + throw new UnknownRequestTypeException('Unknown request type', 1677860080); + } + + if (empty($publicIds)) { + throw new PublicIdMissingException('Missing public id', 1677860090); + } + + return [$requestType, $publicIds,]; + } + + protected function getCloudinaryResource(string $publicId): array + { + $cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId); + + // The resource does not exist, time to fetch + if (!$cloudinaryResource) { + $result = $this->scanService->scanOne($publicId); + if (!$result) { + $message = sprintf('I could not find a corresponding resource for public id %s', $publicId); + throw new CloudinaryNotFoundException($message, 1677859470); + } + $cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId); + } + + return $cloudinaryResource; + } + + protected function clearProcessedFiles(File $file): void + { + $processedFiles = $this->processedFileRepository->findAllByOriginalFile($file); + + foreach ($processedFiles as $processedFile) { + $processedFile->getStorage()->setEvaluatePermissions(false); + $processedFile->delete(); + } + } + + protected function cleanUpTemporaryFile(File $file): void + { + $temporaryFileNameAndPath = CloudinaryFileUtility::getTemporaryFile($file->getStorage()->getUid(), $file->getIdentifier()); + if (is_file($temporaryFileNameAndPath)) { + self::getLogger()->debug($temporaryFileNameAndPath); + unlink($temporaryFileNameAndPath); + } + } + + protected function clearCachePages(File $file): array + { + $tags = []; + foreach ($this->findPagesWithFileReferences($file) as $page) { + $tags[$page['pid']] = 'pageId_' . $page['pid']; + } + + GeneralUtility::makeInstance(CacheManager::class) + ->flushCachesInGroupByTags('pages', $tags); + + $this->eventDispatcher->dispatch( + new ClearCachePageEvent($tags) + ); + + return array_keys($tags); + } + + protected function findPagesWithFileReferences(File $file): array + { + $queryBuilder = $this->getQueryBuilder('sys_file_reference'); + + // @phpstan-ignore-next-line + return $queryBuilder + ->select('pid') + ->from('sys_file_reference') + //->groupBy('pid') // no support for distinct + ->andWhere( + 'pid > 0', + 'uid_local = ' . $file->getUid() + ) + ->execute() + ->fetchAllAssociative(); + } + + protected function canonicalizeAndCheckFileIdentifier(string $fileIdentifier): string + { + return '/' . ltrim($fileIdentifier, '/'); + } + + protected function canonicalizeAndCheckFolderIdentifier(string $folderPath): string + { + return rtrim($this->canonicalizeAndCheckFileIdentifier($folderPath), '/') . '/'; + } + + /** + * We only react for notification type "upload", "rename", "delete" + * @see other notification types + * https://cloudinary.com/documentation/notifications + * + * - create_folder, + * - resource_tags_changed, + * - resource_context_changed + * - ... + */ + protected function shouldStopProcessing(mixed $payload): bool + { + return !( + $this->isRequestUploadOverwrite($payload) || + $this->isRequestRename($payload) || + $this->isRequestDelete($payload) + ); + } + + protected function isRequestUploadOverwrite(mixed $payload): bool + { + return is_array($payload) && + array_key_exists('notification_type', $payload) && + array_key_exists('overwritten', $payload) && + $payload['notification_type'] === self::NOTIFICATION_TYPE_UPLOAD + && $payload['overwritten']; + } + + protected function isRequestRename(mixed $payload): bool + { + return is_array($payload) && + array_key_exists('notification_type', $payload) && + $payload['notification_type'] === self::NOTIFICATION_TYPE_RENAME; + } + + protected function isRequestDelete(mixed $payload): bool + { + return is_array($payload) && + array_key_exists('notification_type', $payload) && + $payload['notification_type'] === self::NOTIFICATION_TYPE_DELETE; + } + + protected function sendResponse(array $data): ResponseInterface + { + return $this->jsonResponse( + (string)json_encode($data) + ); + } + + protected function checkEnvironment(): void + { + $storageUid = $this->settings['storage'] ?? 0; + if ($storageUid <= 0) { + throw new RuntimeException('Check your configuration while calling the cloudinary web hook. I am missing a storage id', 1677583654); + } + } + + protected function getQueryBuilder(string $tableName): QueryBuilder + { + /** @var ConnectionPool $connectionPool */ + $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); + return $connectionPool->getQueryBuilderForTable($tableName); + } + + protected static function getLogger(): Logger + { + /** @var Logger $logger */ + static $logger = null; + // @phpstan-ignore-next-line + if ($logger === null) { + /** @var LogManager $logger */ + $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); + } + return $logger; + } + + protected function getAdminApi(): AdminApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->adminApi(); + } + + protected function getUploadApi(): UploadApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->uploadApi(); + } + +} diff --git a/Classes/Domain/Repository/ExplicitDataCacheRepository.php b/Classes/Domain/Repository/ExplicitDataCacheRepository.php index 1a9d68b..93ac5f8 100644 --- a/Classes/Domain/Repository/ExplicitDataCacheRepository.php +++ b/Classes/Domain/Repository/ExplicitDataCacheRepository.php @@ -48,7 +48,7 @@ public function findByStorageAndPublicIdAndOptions(int $storageId, string $publi ) ) ); - $item = $query->execute()->fetch(); + $item = $query->execute()->fetchAssociative(); if (!$item) { return null; diff --git a/Classes/Driver/CloudinaryDriver.php b/Classes/Driver/CloudinaryDriver.php index 127570c..a7b8787 100644 --- a/Classes/Driver/CloudinaryDriver.php +++ b/Classes/Driver/CloudinaryDriver.php @@ -8,116 +8,68 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ + +use Cloudinary\Api\Admin\AdminApi; +use Cloudinary\Api\Upload\UploadApi; +use Exception; use TYPO3\CMS\Core\Http\ApplicationType; -use TYPO3\CMS\Core\Charset\CharsetConverter; -use TYPO3\CMS\Core\Log\Logger; use TYPO3\CMS\Core\Resource\Exception\InvalidFileNameException; -use Cloudinary\Api\NotFound; -use TYPO3\CMS\Core\Messaging\FlashMessageQueue; -use Cloudinary\Api; -use Cloudinary\Search; -use Cloudinary\Uploader; +use RuntimeException; +use TYPO3\CMS\Core\Charset\CharsetConverter; use TYPO3\CMS\Core\Core\Environment; -use TYPO3\CMS\Core\Type\File\FileInfo; -use Visol\Cloudinary\Cache\CloudinaryTypo3Cache; +use TYPO3\CMS\Core\Log\Logger; +use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Log\LogLevel; use TYPO3\CMS\Core\Log\LogManager; -use TYPO3\CMS\Core\Messaging\FlashMessage; -use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\PathUtility; use TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver; -use TYPO3\CMS\Core\Resource\Exception; use TYPO3\CMS\Core\Resource\ResourceStorage; -use TYPO3\CMS\Extbase\Object\ObjectManager; -use TYPO3\CMS\Extbase\SignalSlot\Dispatcher; -use TYPO3\CMS\Extbase\Utility\LocalizationUtility; +use Visol\Cloudinary\Domain\Repository\ExplicitDataCacheRepository; +use Visol\Cloudinary\Services\CloudinaryFolderService; +use Visol\Cloudinary\Services\CloudinaryResourceService; use Visol\Cloudinary\Services\CloudinaryPathService; +use Visol\Cloudinary\Services\CloudinaryTestConnectionService; +use Visol\Cloudinary\Services\ConfigurationService; use Visol\Cloudinary\Utility\CloudinaryApiUtility; +use Visol\Cloudinary\Utility\CloudinaryFileUtility; +use Visol\Cloudinary\Utility\MimeTypeUtility; +use function str_starts_with; -/** - * Class CloudinaryDriver - * - * @obsolete - */ class CloudinaryDriver extends AbstractHierarchicalFilesystemDriver { public const DRIVER_TYPE = 'VisolCloudinary'; - const ROOT_FOLDER_IDENTIFIER = '/'; - const UNSAFE_FILENAME_CHARACTER_EXPRESSION = '\\x00-\\x2C\\/\\x3A-\\x3F\\x5B-\\x60\\x7B-\\xBF'; - - /** - * The base URL that points to this driver's storage. As long is this is not set, it is assumed that this folder - * is not publicly available - * - * @var string - */ - protected $baseUrl = ''; - /** - * @var array[] - */ - protected $cachedCloudinaryResources = []; + protected const ROOT_FOLDER_IDENTIFIER = '/'; - /** - * @var array - */ - protected $cachedFolders = []; + protected const UNSAFE_FILENAME_CHARACTER_EXPRESSION = '\\x00-\\x2C\\/\\x3A-\\x3F\\x5B-\\x60\\x7B-\\xBF'; - /** - * Object permissions are cached here in subarrays like: - * $identifier => ['r' => bool, 'w' => bool] - * - * @var array - */ - protected $cachedPermissions = []; + public const PROCESSEDFILE_IDENTIFIER_PREFIX = 'PROCESSEDFILE'; - /** - * Cache to avoid creating multiple local files since it is time consuming. - * We must download the file. - * - * @var array - */ - protected $localProcessingFiles = []; + static public array $knownRawFormats = ['youtube', 'vimeo']; /** - * @var ResourceStorage + * The base URL that points to this driver's storage. As long is this is not set, it is assumed that this folder + * is not publicly available */ - protected $storage = null; + protected string $baseUrl = ''; /** - * @var CharsetConverter + * Object permissions are cached here in subarrays like: + * $identifier => ['r' => bool, 'w' => bool] */ - protected $charsetConversion = null; + protected array $cachedPermissions = []; - /** - * @var string - */ - protected $languageFile = 'LLL:EXT:cloudinary/Resources/Private/Language/backend.xlf'; + protected ConfigurationService $configurationService; - /** - * @var Dispatcher - */ - protected $signalSlotDispatcher; + protected CharsetConverter $charsetConversion; - /** - * @var Api $api - */ - protected $api; + protected ?CloudinaryPathService $cloudinaryPathService = null; - /** - * @var CloudinaryTypo3Cache - */ - protected $cloudinaryTypo3Cache; + protected ?CloudinaryResourceService $cloudinaryResourceService = null; - /** - * @var CloudinaryPathService - */ - protected $cloudinaryPathService; + protected ?CloudinaryFolderService $cloudinaryFolderService = null; - /** - * @param array $configuration - */ public function __construct(array $configuration = []) { $this->configuration = $configuration; @@ -128,44 +80,56 @@ public function __construct(array $configuration = []) ResourceStorage::CAPABILITY_BROWSABLE | ResourceStorage::CAPABILITY_PUBLIC | ResourceStorage::CAPABILITY_WRITABLE; + + $this->configurationService = GeneralUtility::makeInstance(ConfigurationService::class, $this->configuration); + + $this->charsetConversion = GeneralUtility::makeInstance(CharsetConverter::class); } - /** - * @return void - */ - public function processConfiguration() + public function processConfiguration(): void { } - /** - * @return void - */ - public function initialize() + public function initialize(): void { // Test connection if we are in the edit view of this storage - if (ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend() && !empty($_GET['edit']['sys_file_storage'])) { - $this->testConnection(); + if ( + !Environment::isCli() && + ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend() && + !empty($_GET['edit']['sys_file_storage']) + ) { + $this->getCloudinaryTestConnectionService()->test(); } } /** - * @param string $fileIdentifier - * - * @return string + * @param string $identifier */ - public function getPublicUrl($fileIdentifier) + public function getPublicUrl($identifier): string + { + if ($this->isProcessedFile($identifier)) { + return $this->getPublicBaseUrl() . $this->getProcessedFileUri($identifier); + } + + $cloudinaryResource = $this->getCloudinaryResourceService()->getResource( + $this->getCloudinaryPathService()->computeCloudinaryPublicId($identifier), + ); + + return $cloudinaryResource ? $cloudinaryResource['secure_url'] : ''; + } + + public function getPublicBaseUrl(): string { - return $this->resourceExists($fileIdentifier) - ? $this->getCachedCloudinaryResource($fileIdentifier)['secure_url'] - : ''; + $cname = $this->configurationService->get('cname'); + $publicBaseUrl = empty($cname) + ? 'https://res.cloudinary.com/' . $this->configurationService->get('cloudName') + : 'https://' . $this->configurationService->get('cname') + ; + + return $publicBaseUrl; } - /** - * @param string $message - * @param array $arguments - * @param array $data - */ - protected function log(string $message, array $arguments = [], array $data = []) + protected function log(string $message, array $arguments = [], array $data = []): void { /** @var Logger $logger */ $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); @@ -177,88 +141,49 @@ protected function log(string $message, array $arguments = [], array $data = []) * * @param string $fileIdentifier * @param string $hashAlgorithm - * - * @return string */ - public function hash($fileIdentifier, $hashAlgorithm) + public function hash($fileIdentifier, $hashAlgorithm): string { return $this->hashIdentifier($fileIdentifier); } - /** - * Returns the identifier of the default folder new files should be put into. - * - * @return string - */ - public function getDefaultFolder() + public function getDefaultFolder(): string { return $this->getRootLevelFolder(); } - /** - * Returns the identifier of the root level folder of the storage. - * - * @return string - */ - public function getRootLevelFolder() + public function getRootLevelFolder(): string { return DIRECTORY_SEPARATOR; } /** - * Returns information about a file. - * * @param string $fileIdentifier * @param array $propertiesToExtract Array of properties which are be extracted * If empty all will be extracted - * - * @return array - * @throws \Exception */ - public function getFileInfoByIdentifier($fileIdentifier, array $propertiesToExtract = []) + public function getFileInfoByIdentifier($fileIdentifier, array $propertiesToExtract = []): array { - $this->log( - 'Just a notice! Time consuming action ahead. I am going to download a file "%s"', - [$fileIdentifier], - ['getFileInfoByIdentifier'], - ); - - $cloudinaryResource = $this->getCachedCloudinaryResource($fileIdentifier); - - // True at the indexation of the file - // Cloudinary is asynchronous and we might not have the resource at hand. - // Call it one more time to double check! - + if ($this->isProcessedFile($fileIdentifier)) { + return []; + } + $publicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); + $cloudinaryResource = $this->getCloudinaryResourceService()->getResource($publicId); + // We have a problem Hudson! if (!$cloudinaryResource) { - $cloudinaryResource = $this->getCloudinaryResource($fileIdentifier); - $this->flushFileCache(); // We flush the cache.... - - // This time we have a problem! - if (!$cloudinaryResource) { - throw new \Exception( - 'I could not find a corresponding cloudinary resource for file ' . $fileIdentifier, - 1591775048, - ); - } + throw new Exception( + 'I could not find a corresponding cloudinary resource for file ' . $fileIdentifier, + 1591775048, + ); } - // We are force to download the file in order to correctly find the mime type. - $localFile = $this->getFileForLocalProcessing($fileIdentifier); - - /** @var FileInfo $fileInfo */ - $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $localFile); - $extension = PathUtility::pathinfo($localFile, PATHINFO_EXTENSION); - $mimeType = $fileInfo->getMimeType(); - - $canonicalFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier(PathUtility::dirname($fileIdentifier)); - - $values = [ + return [ 'identifier_hash' => $this->hashIdentifier($fileIdentifier), - 'folder_hash' => sha1($canonicalFolderIdentifier), + 'folder_hash' => sha1($this->canonicalizeAndCheckFolderIdentifier(PathUtility::dirname($fileIdentifier))), 'creation_date' => strtotime($cloudinaryResource['created_at']), 'modification_date' => strtotime($cloudinaryResource['created_at']), - 'mime_type' => $mimeType, - 'extension' => $extension, + 'mime_type' => MimeTypeUtility::guessMimeType($cloudinaryResource['format']), + 'extension' => $this->getResourceInfo($cloudinaryResource, 'format'), 'size' => $this->getResourceInfo($cloudinaryResource, 'bytes'), 'width' => $this->getResourceInfo($cloudinaryResource, 'width'), 'height' => $this->getResourceInfo($cloudinaryResource, 'height'), @@ -266,89 +191,74 @@ public function getFileInfoByIdentifier($fileIdentifier, array $propertiesToExtr 'identifier' => $fileIdentifier, 'name' => PathUtility::basename($fileIdentifier), ]; - - return $values; } - /** - * @param array $resource - * @param string $name - * - * @return string - */ protected function getResourceInfo(array $resource, string $name): string { return $resource[$name] ?? ''; } /** - * Checks if a file exists - * - * @param string $identifier - * - * @return bool + * @param string $fileIdentifier */ - public function fileExists($identifier) + public function fileExists($fileIdentifier): bool { - if (substr($identifier, -1) === DIRECTORY_SEPARATOR || $identifier === '') { - return false; + // Early return in case we have a processed file. + if ($this->isProcessedFile($fileIdentifier)) { + return true; } - return $this->resourceExists($identifier); + + $cloudinaryResource = $this->getCloudinaryResourceService()->getResource( + $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier), + ); + + return !empty($cloudinaryResource); } /** - * Checks if a folder exists - * * @param string $folderIdentifier - * - * @return bool */ - public function folderExists($folderIdentifier) + public function folderExists($folderIdentifier): bool { - try { - // Will trigger an exception if the folder identifier does not exist. - $subFolders = $this->getFoldersInFolder($folderIdentifier); - } catch (\Exception $e) { - return false; + // Early return in case we have a processed file. + if ($this->isProcessedFolder($folderIdentifier)) { + return true; + } + + if ($folderIdentifier === self::ROOT_FOLDER_IDENTIFIER) { + return true; } - return is_array($subFolders); + $cloudinaryFolder = $this->getCloudinaryFolderService()->getFolder( + $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier), + ); + return !empty($cloudinaryFolder); } /** * @param string $fileName * @param string $folderIdentifier - * - * @return bool */ - public function fileExistsInFolder($fileName, $folderIdentifier) + public function fileExistsInFolder($fileName, $folderIdentifier): bool { - $fileIdentifier = $folderIdentifier . $fileName; - return $this->resourceExists($fileIdentifier); + $fileIdentifier = $this->canonicalizeFolderIdentifierAndFileName($folderIdentifier, $fileName); + + return $this->fileExists($fileIdentifier); } /** - * Checks if a folder exists inside a storage folder - * * @param string $folderName * @param string $folderIdentifier - * - * @return bool */ - public function folderExistsInFolder($folderName, $folderIdentifier) + public function folderExistsInFolder($folderName, $folderIdentifier): bool { - $canonicalFolderPath = $this->canonicalizeAndCheckFolderIdentifierAndFolderName($folderIdentifier, $folderName); - return $this->folderExists($canonicalFolderPath); + return $this->folderExists($this->canonicalizeFolderIdentifierAndFolderName($folderIdentifier, $folderName)); } /** - * Returns the Identifier for a folder within a given folder. - * * @param string $folderName The name of the target folder * @param string $folderIdentifier - * - * @return string */ - public function getFolderInFolder($folderName, $folderIdentifier) + public function getFolderInFolder($folderName, $folderIdentifier): string { return $folderIdentifier . DIRECTORY_SEPARATOR . $folderName; } @@ -359,44 +269,39 @@ public function getFolderInFolder($folderName, $folderIdentifier) * @param string $newFileName optional, if not given original name is used * @param bool $removeOriginal if set the original file will be removed * after successful operation - * - * @return string the identifier of the new file - * @throws \Exception */ - public function addFile($localFilePath, $targetFolderIdentifier, $newFileName = '', $removeOriginal = true) + public function addFile($localFilePath, $targetFolderIdentifier, $newFileName = '', $removeOriginal = true): string { $fileName = $this->sanitizeFileName($newFileName !== '' ? $newFileName : PathUtility::basename($localFilePath)); - $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier( - $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier) . $fileName, - ); + $fileIdentifier = $this->canonicalizeFolderIdentifierAndFileName($targetFolderIdentifier, $fileName); - // Necessary to happen in an early stage. - $this->log('[CACHE] Flushed as adding file', [], ['addFile']); - $this->flushFileCache(); + // We remove a possible existing transient file to avoid bad surprise. + $this->cleanUpTemporaryFile($fileIdentifier); + // We compute the cloudinary public id $cloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); $this->log( - '[API][UPLOAD] Cloudinary\Uploader::upload() - add resource "%s"', + '[API] UploadApi - add resource "%s"', [$cloudinaryPublicId], ['addFile()'], ); - // Before calling API, make sure we are connected with the right "bucket" - $this->initializeApi(); - // Upload the file - $resource = Uploader::upload($localFilePath, [ - 'public_id' => PathUtility::basename($cloudinaryPublicId), - 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath($targetFolderIdentifier), - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - 'overwrite' => true, - ]); + $cloudinaryResource = (array)$this->getUploadApi()->upload( + $localFilePath, [ + 'public_id' => PathUtility::basename($cloudinaryPublicId), + 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath($targetFolderIdentifier), + 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), + 'overwrite' => true, + ] + ); - if (!$resource && $resource['type'] !== 'upload') { - throw new \RuntimeException('Cloudinary upload failed for ' . $fileIdentifier, 1591954943); - } + $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); + + // We persist the uploaded resource. + $this->getCloudinaryResourceService()->save($cloudinaryResource); return $fileIdentifier; } @@ -405,152 +310,142 @@ public function addFile($localFilePath, $targetFolderIdentifier, $newFileName = * @param string $fileIdentifier * @param string $targetFolderIdentifier * @param string $newFileName - * - * @return string */ - public function moveFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $newFileName) + public function moveFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $newFileName): string { $targetIdentifier = $targetFolderIdentifier . $newFileName; return $this->renameFile($fileIdentifier, $targetIdentifier); } /** - * Copies a file *within* the current storage. - * Note that this is only about an inner storage copy action, - * where a file is just copied to another folder in the same storage. - * * @param string $fileIdentifier * @param string $targetFolderIdentifier * @param string $fileName - * - * @return string the Identifier of the new file */ - public function copyFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $fileName) + public function copyFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $fileName): string { - // Flush the file cache entries - $this->log('[CACHE] Flushed as copying file', [], ['copyFileWithinStorage']); - $this->flushFileCache(); - - // Before calling API, make sure we are connected with the right "bucket" - $this->initializeApi(); + $targetFileIdentifier = $this->canonicalizeFolderIdentifierAndFileName($targetFolderIdentifier, $fileName); - Uploader::upload($this->getPublicUrl($fileIdentifier), [ + $cloudinaryResource = (array)$this->getUploadApi()->upload($this->getPublicUrl($fileIdentifier), [ 'public_id' => PathUtility::basename( - $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileName), + $this->getCloudinaryPathService()->computeCloudinaryPublicId($targetFileIdentifier), ), 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath($targetFolderIdentifier), 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), 'overwrite' => true, ]); - $targetIdentifier = $targetFolderIdentifier . $fileName; - return $targetIdentifier; + $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); + + // We persist the uploaded resource + $this->getCloudinaryResourceService()->save($cloudinaryResource); + + return $targetFileIdentifier; } /** - * Replaces a file with file in local file system. - * * @param string $fileIdentifier * @param string $localFilePath - * - * @return bool */ - public function replaceFile($fileIdentifier, $localFilePath) + public function replaceFile($fileIdentifier, $localFilePath): bool { + // We remove a possible existing transient file to avoid bad surprise. + $this->cleanUpTemporaryFile($fileIdentifier); + $cloudinaryPublicId = PathUtility::basename( $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier), ); - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( - PathUtility::dirname($fileIdentifier), - ); - $options = [ - 'public_id' => $cloudinaryPublicId, - 'folder' => $cloudinaryFolder, + // Upload the file + $cloudinaryResource = (array)$this->getUploadApi()->upload($localFilePath, [ + 'public_id' => PathUtility::basename($cloudinaryPublicId), + 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath( + PathUtility::dirname($fileIdentifier), + ), 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), 'overwrite' => true, - ]; + ]); - // Flush the file cache entries - $this->log('[CACHE] Flushed as replacing file', [], ['replaceFile']); - $this->flushFileCache(); + $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); - // Before calling the API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - // Upload the file - Uploader::upload($localFilePath, $options); + // We persist the uploaded resource. + $this->getCloudinaryResourceService()->save($cloudinaryResource); return true; } /** - * Removes a file from the filesystem. This does not check if the file is - * still used or if it is a bad idea to delete it for some other reason - * this has to be taken care of in the upper layers (e.g. the Storage)! - * * @param string $fileIdentifier - * - * @return bool TRUE if deleting the file succeeded */ - public function deleteFile($fileIdentifier) + public function deleteFile($fileIdentifier): bool { - // Necessary to happen in an early stage. - $this->log('[CACHE] Flushed as deleting file', [], ['deleteFile']); - $this->flushFileCache(); + if ($this->isProcessedFile($fileIdentifier)) { + /* + * Processed files do not really exist in the Cloudinary file system + * Just remove the cache entry and confirm the deletion. + */ + $this->getCloudinaryResourceService()->delete($fileIdentifier); + return true; + } $cloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); $this->log( - '[API][DELETE] Cloudinary\Api::delete_resources - delete resource "%s"', + '[API] Delete resource "%s"', [$cloudinaryPublicId], ['deleteFile'], ); - $response = $this->getApi()->delete_resources($cloudinaryPublicId, [ + $response = $this->getAdminApi()->deleteAssets($cloudinaryPublicId, [ 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), ]); - $key = is_array($response['deleted']) ? key($response['deleted']) : ''; + $isDeleted = false; - return is_array($response['deleted']) && - isset($response['deleted'][$key]) && - $response['deleted'][$key] === 'deleted'; + foreach ($response['deleted'] as $publicId => $status) { + if ($status === 'deleted') { + $isDeleted = (bool)$this->getCloudinaryResourceService()->delete($publicId); + } + } + + return $isDeleted; } /** - * Removes a folder in filesystem. - * * @param string $folderIdentifier * @param bool $deleteRecursively - * - * @return bool - * @throws Api\GeneralError */ - public function deleteFolder($folderIdentifier, $deleteRecursively = false) + public function deleteFolder($folderIdentifier, $deleteRecursively = false): bool { $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier); + if ($deleteRecursively) { $this->log( - '[API][DELETE] Cloudinary\Api::delete_resources_by_prefix() - folder "%s"', + '[API] Delete folder "%s"', [$cloudinaryFolder], ['deleteFolder'], ); - $this->getApi()->delete_resources_by_prefix($cloudinaryFolder); + $response = $this->getAdminApi()->deleteAssetsByPrefix($cloudinaryFolder); + + foreach ($response['deleted'] as $publicId => $status) { + if ($status === 'deleted') { + $this->getCloudinaryResourceService()->delete($publicId); + } + } } // We make sure the folder exists first. It will also delete sub-folder if those ones are empty. if ($this->folderExists($folderIdentifier)) { $this->log( - '[API][DELETE] Cloudinary\Api::delete_folder() - folder "%s"', + '[API] Delete folder "%s"', [$cloudinaryFolder], ['deleteFolder'], ); - $this->getApi()->delete_folder($cloudinaryFolder); - } + $response = $this->getAdminApi()->deleteFolder($cloudinaryFolder); - // Flush the folder cache entries - $this->log('[CACHE][FOLDER] Flushed as deleting folder', [], ['deleteFolder']); - $this->flushFolderCache(); + foreach ($response['deleted'] as $folder) { + $this->getCloudinaryFolderService()->delete($folder); + } + } return true; } @@ -558,10 +453,8 @@ public function deleteFolder($folderIdentifier, $deleteRecursively = false) /** * @param string $fileIdentifier * @param bool $writable - * - * @return string */ - public function getFileForLocalProcessing($fileIdentifier, $writable = true) + public function getFileForLocalProcessing($fileIdentifier, $writable = true): string { $temporaryPath = $this->getTemporaryPathForFile($fileIdentifier); @@ -572,73 +465,53 @@ public function getFileForLocalProcessing($fileIdentifier, $writable = true) ['getFileForLocalProcessing'], ); - $cloudinaryResource = $this->getCloudinaryResource($fileIdentifier); - - // We have a problem! - if (!$cloudinaryResource) { - throw new \Exception( - 'I could not find a corresponding cloudinary resource for file ' . $fileIdentifier, - 1591775049, - ); - } - + file_put_contents($temporaryPath, file_get_contents($this->getPublicUrl($fileIdentifier))); $this->log('File downloaded into "%s"', [$temporaryPath], ['getFileForLocalProcessing']); - file_put_contents($temporaryPath, file_get_contents($cloudinaryResource['secure_url'])); } return $temporaryPath; } /** - * Creates a new (empty) file and returns the identifier. - * * @param string $fileName * @param string $parentFolderIdentifier - * - * @return string */ - public function createFile($fileName, $parentFolderIdentifier) + public function createFile($fileName, $parentFolderIdentifier): string { - throw new \RuntimeException( + throw new RuntimeException( 'createFile: not implemented action! Cloudinary Driver is limited to images.', 1570728107, ); } /** - * Creates a folder, within a parent folder. - * If no parent folder is given, a root level folder will be created - * * @param string $newFolderName * @param string $parentFolderIdentifier * @param bool $recursive - * - * @return string the Identifier of the new folder */ - public function createFolder($newFolderName, $parentFolderIdentifier = '', $recursive = false) + public function createFolder($newFolderName, $parentFolderIdentifier = '', $recursive = false): string { - $canonicalFolderPath = $this->canonicalizeAndCheckFolderIdentifierAndFolderName( + $canonicalFolderPath = $this->canonicalizeFolderIdentifierAndFolderName( $parentFolderIdentifier, $newFolderName, ); - $cloudinaryFolder = $this->getCloudinaryPathService()->normalizeCloudinaryPath($canonicalFolderPath); + $cloudinaryFolder = $this->getCloudinaryPathService()->normalizeCloudinaryPublicId($canonicalFolderPath); - $this->log('[API][CREATE] Cloudinary\Api::createFolder() - folder "%s"', [$cloudinaryFolder], ['createFolder']); - $this->getApi()->create_folder($cloudinaryFolder); + $this->log('[API] Create folder "%s"', [$cloudinaryFolder], ['createFolder']); + $response = $this->getAdminApi()->createFolder($cloudinaryFolder); - // Flush the folder cache entries - $this->log('[CACHE][FOLDER] Flushed as creating folder', [], ['createFolder']); - $this->flushFolderCache(); + if (!$response['success']) { + throw new Exception('Folder creation failed: ' . $cloudinaryFolder, 1591775050); + } + $this->getCloudinaryFolderService()->save($cloudinaryFolder); return $canonicalFolderPath; } /** * @param string $fileIdentifier - * - * @return string */ - public function getFileContents($fileIdentifier) + public function getFileContents($fileIdentifier): string { // Will download the file to be faster next time the content is required. $localFileNameAndPath = $this->getFileForLocalProcessing($fileIdentifier); @@ -650,23 +523,17 @@ public function getFileContents($fileIdentifier) * * @param string $fileIdentifier * @param string $contents - * - * @return int */ public function setFileContents($fileIdentifier, $contents) { - throw new \RuntimeException('setFileContents: not implemented action!', 1570728106); + throw new RuntimeException('setFileContents: not implemented action!', 1570728106); } /** - * Renames a file in this storage. - * * @param string $fileIdentifier * @param string $newFileIdentifier The target path (including the file name!) - * - * @return string The identifier of the file after renaming */ - public function renameFile($fileIdentifier, $newFileIdentifier) + public function renameFile($fileIdentifier, $newFileIdentifier): string { if (!$this->isFileIdentifier($newFileIdentifier)) { $sanitizedFileName = $this->sanitizeFileName(PathUtility::basename($newFileIdentifier)); @@ -680,70 +547,67 @@ public function renameFile($fileIdentifier, $newFileIdentifier) $newCloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($newFileIdentifier); if ($cloudinaryPublicId !== $newCloudinaryPublicId) { - // Necessary to happen in an early stage. - - $this->log('[CACHE] Flushed as renaming file', [], ['renameFile']); - $this->flushFileCache(); - - // Before calling API, make sure we are connected with the right "bucket" - $this->initializeApi(); // Rename the file - Uploader::rename($cloudinaryPublicId, $newCloudinaryPublicId, [ + $cloudinaryResource = (array)$this->getUploadApi()->rename($cloudinaryPublicId, $newCloudinaryPublicId, [ 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), + 'overwrite' => true, ]); + + $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); + + // We remove the old public id + $this->getCloudinaryResourceService()->delete($cloudinaryPublicId); + + // ... and insert the new cloudinary resource + $this->getCloudinaryResourceService()->save($cloudinaryResource); } return $newFileIdentifier; } /** - * Renames a folder in this storage. - * + * @param array $cloudinaryResource + * @param string $fileIdentifier + */ + protected function checkCloudinaryUploadStatus(array $cloudinaryResource, $fileIdentifier): void + { + if (!$cloudinaryResource && $cloudinaryResource['type'] !== 'upload') { + throw new RuntimeException('Cloudinary upload failed for ' . $fileIdentifier, 1591954950); + } + } + + /** * @param string $folderIdentifier * @param string $newFolderName * * @return array A map of old to new file identifiers of all affected resources */ - public function renameFolder($folderIdentifier, $newFolderName) + public function renameFolder($folderIdentifier, $newFolderName): array { $renamedFiles = []; - foreach ($this->getFilesInFolder($folderIdentifier, 0, -1) as $fileIdentifier) { - $resource = $this->getCachedCloudinaryResource($fileIdentifier); - $cloudinaryPublicId = $resource['public_id']; - - $pathSegments = GeneralUtility::trimExplode('/', $cloudinaryPublicId); - - $numberOfSegments = count($pathSegments); - if ($numberOfSegments > 1) { - // Replace last folder name by the new folder name - $pathSegments[$numberOfSegments - 2] = $newFolderName; - $newCloudinaryPublicId = implode('/', $pathSegments); - - if ($cloudinaryPublicId !== $newCloudinaryPublicId) { - // Flush files + folder cache - $this->flushCache(); - - // Before calling the API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - // Rename the file - Uploader::rename($cloudinaryPublicId, $newCloudinaryPublicId, [ - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - ]); - $oldFileIdentifier = $this->getCloudinaryPathService()->computeFileIdentifier($resource); - $newFileIdentifier = $this->getCloudinaryPathService()->computeFileIdentifier([ - 'public_id' => $newCloudinaryPublicId, - 'format' => $resource['format'], - ]); - $renamedFiles[$oldFileIdentifier] = $newFileIdentifier; + $pathSegments = GeneralUtility::trimExplode('/', $folderIdentifier); + $numberOfSegments = count($pathSegments); + + if ($numberOfSegments > 1) { + // Replace last folder name by the new folder name + $pathSegments[$numberOfSegments - 2] = $newFolderName; + $newFolderIdentifier = implode('/', $pathSegments); + + $renamedFiles[$folderIdentifier] = $newFolderIdentifier; + + foreach ($this->getFilesInFolder($folderIdentifier, 0, -1, true) as $oldFileIdentifier) { + $newFileIdentifier = str_replace($folderIdentifier, $newFolderIdentifier, $oldFileIdentifier); + + if ($oldFileIdentifier !== $newFileIdentifier) { + $renamedFiles[$oldFileIdentifier] = $this->renameFile($oldFileIdentifier, $newFileIdentifier); } } - } - // After working so hard, delete the old empty folder. - $this->deleteFolder($folderIdentifier); + // After working so hard, delete the old empty folder. + $this->deleteFolder($folderIdentifier); + } return $renamedFiles; } @@ -755,10 +619,14 @@ public function renameFolder($folderIdentifier, $newFolderName) * * @return array All files which are affected, map of old => new file identifiers */ - public function moveFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName) + public function moveFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName): array { // Compute the new folder identifier and then create it. - $newTargetFolderIdentifier = $targetFolderIdentifier . $newFolderName . DIRECTORY_SEPARATOR; + $newTargetFolderIdentifier = $this->canonicalizeFolderIdentifierAndFolderName( + $targetFolderIdentifier, + $newFolderName, + ); + if (!$this->folderExists($newTargetFolderIdentifier)) { $this->createFolder($newTargetFolderIdentifier); } @@ -783,13 +651,11 @@ public function moveFolderWithinStorage($sourceFolderIdentifier, $targetFolderId * @param string $sourceFolderIdentifier * @param string $targetFolderIdentifier * @param string $newFolderName - * - * @return bool */ - public function copyFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName) + public function copyFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName): bool { // Compute the new folder identifier and then create it. - $newTargetFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifierAndFolderName( + $newTargetFolderIdentifier = $this->canonicalizeFolderIdentifierAndFolderName( $targetFolderIdentifier, $newFolderName, ); @@ -798,11 +664,13 @@ public function copyFolderWithinStorage($sourceFolderIdentifier, $targetFolderId $this->createFolder($newTargetFolderIdentifier); } - $files = $this->getFilesInFolder($sourceFolderIdentifier, 0, -1); + $files = $this->getFilesInFolder($sourceFolderIdentifier, 0, -1, true); foreach ($files as $fileIdentifier) { + $newFileIdentifier = str_replace($sourceFolderIdentifier, $newTargetFolderIdentifier, $fileIdentifier); + $this->copyFileWithinStorage( $fileIdentifier, - $newTargetFolderIdentifier, + GeneralUtility::dirname($newFileIdentifier), PathUtility::basename($fileIdentifier), ); } @@ -814,42 +682,17 @@ public function copyFolderWithinStorage($sourceFolderIdentifier, $targetFolderId * Checks if a folder contains files and (if supported) other folders. * * @param string $folderIdentifier - * - * @return bool TRUE if there are no files and folders within $folder */ - public function isFolderEmpty($folderIdentifier) + public function isFolderEmpty($folderIdentifier): bool { - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier); - $this->log( - '[API] Cloudinary\Api::resources() - fetch files from folder "%s"', - [$cloudinaryFolder], - ['isFolderEmpty'], - ); - $response = $this->getApi()->resources([ - 'resource_type' => 'image', - 'type' => 'upload', - 'max_results' => 1, - 'prefix' => $cloudinaryFolder, - ]); - - return empty($response['resources']); + return $this->getCloudinaryFolderService()->countSubFolders($folderIdentifier); } /** - * Checks if a given identifier is within a container, e.g. if - * a file or folder is within another folder. - * This can e.g. be used to check for web-mounts. - * - * Hint: this also needs to return TRUE if the given identifier - * matches the container identifier to allow access to the root - * folder of a filemount. - * * @param string $folderIdentifier * @param string $identifier identifier to be checked against $folderIdentifier - * - * @return bool TRUE if $content is within or matches $folderIdentifier */ - public function isWithin($folderIdentifier, $identifier) + public function isWithin($folderIdentifier, $identifier): bool { $folderIdentifier = $this->canonicalizeAndCheckFileIdentifier($folderIdentifier); $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier($identifier); @@ -863,219 +706,217 @@ public function isWithin($folderIdentifier, $identifier) $folderIdentifier .= DIRECTORY_SEPARATOR; } - return \str_starts_with($fileIdentifier, $folderIdentifier); + return str_starts_with($fileIdentifier, $folderIdentifier); } /** - * Returns information about a file. - * * @param string $folderIdentifier - * - * @return array */ - public function getFolderInfoByIdentifier($folderIdentifier) + public function getFolderInfoByIdentifier($folderIdentifier): array { $canonicalFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); return [ 'identifier' => $canonicalFolderIdentifier, 'name' => PathUtility::basename( - $this->getCloudinaryPathService()->normalizeCloudinaryPath($canonicalFolderIdentifier), + $this->getCloudinaryPathService()->normalizeCloudinaryPublicId($canonicalFolderIdentifier), ), 'storage' => $this->storageUid, ]; } /** - * Returns a file inside the specified path - * * @param string $fileName * @param string $folderIdentifier - * - * @return string File Identifier */ - public function getFileInFolder($fileName, $folderIdentifier) + public function getFileInFolder($fileName, $folderIdentifier): string { $folderIdentifier = $folderIdentifier . DIRECTORY_SEPARATOR . $fileName; return $folderIdentifier; } /** - * Returns a list of files inside the specified path - * * @param string $folderIdentifier * @param int $start * @param int $numberOfItems * @param bool $recursive - * @param array $filenameFilterCallbacks callbacks for filtering the items + * @param array $filterCallbacks callbacks for filtering the items * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) - * - * @return array of FileIdentifiers */ public function getFilesInFolder( $folderIdentifier, $start = 0, $numberOfItems = 40, $recursive = false, - array $filenameFilterCallbacks = [], + array $filterCallbacks = [], $sort = '', $sortRev = false - ) { - if ($folderIdentifier === '') { - throw new \RuntimeException( - 'Something went wrong in method "getFilesInFolder"! $folderIdentifier can not be empty', - 1574754623, - ); - } + ): array + { + $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( + $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier), + ); - if (!isset($this->cachedCloudinaryResources[$folderIdentifier])) { - // Try to fetch from the cache - $this->cachedCloudinaryResources[$folderIdentifier] = $this->getCache()->getCachedFiles($folderIdentifier); + // Set default orderings + $parameters = (array)GeneralUtility::_GP('SET'); - // If not found in TYPO3 cache, ask Cloudinary - if (!is_array($this->cachedCloudinaryResources[$folderIdentifier])) { - $this->cachedCloudinaryResources[$folderIdentifier] = $this->getCloudinaryResources($folderIdentifier); - } + $orderField = $parameters['sort'] ?? 'filename'; + if ($orderField === 'file') { + $orderField = 'filename'; + } elseif ($orderField === 'tstamp') { + $orderField = 'created_at'; } - // Set default sorting - $parameters = (array) GeneralUtility::_GP('SET'); - if (empty($parameters)) { - $parameters['sort'] = 'file'; - $parameters['reverse'] = 0; - } + $orderings = [ + 'fieldName' => $orderField, + 'direction' => isset($parameters['reverse']) && (int)$parameters['reverse'] ? 'DESC' : 'ASC', + ]; - // Sort files - if ($parameters['sort'] === 'file') { - if ((int) $parameters['reverse']) { - uasort( - $this->cachedCloudinaryResources[$folderIdentifier], - '\Visol\Cloudinary\Utility\SortingUtility::sortByFileNameDesc', - ); - } else { - uasort( - $this->cachedCloudinaryResources[$folderIdentifier], - '\Visol\Cloudinary\Utility\SortingUtility::sortByFileNameAsc', - ); - } - } elseif ($parameters['sort'] === 'tstamp') { - if ((int) $parameters['reverse']) { - uasort( - $this->cachedCloudinaryResources[$folderIdentifier], - '\Visol\Cloudinary\Utility\SortingUtility::sortByTimeStampDesc', - ); - } else { - uasort( - $this->cachedCloudinaryResources[$folderIdentifier], - '\Visol\Cloudinary\Utility\SortingUtility::sortByTimeStampAsc', - ); - } - } + $pagination = [ + 'maxResult' => $numberOfItems, + 'firstResult' => (int)GeneralUtility::_GP('pointer'), + ]; + + $cloudinaryResources = $this->getCloudinaryResourceService()->getResources( + $cloudinaryFolder, + $orderings, + $pagination, + $recursive, + ); - // Pagination - if ($numberOfItems > 0) { - $files = array_slice( - $this->cachedCloudinaryResources[$folderIdentifier], - (int) GeneralUtility::_GP('pointer'), - $numberOfItems, + // Generate list of folders for the file module. + $files = []; + foreach ($cloudinaryResources as $cloudinaryResource) { + // Compute file identifier + $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier( + $this->getCloudinaryPathService()->computeFileIdentifier($cloudinaryResource), ); - } else { - $files = $this->cachedCloudinaryResources[$folderIdentifier]; + + $result = $this->applyFilterMethodsToDirectoryItem( + $filterCallbacks, + basename($fileIdentifier), + $fileIdentifier, + dirname($fileIdentifier), + ); + + if ($result) { + $files[] = $fileIdentifier; + } } - return array_keys($files); + return $files; } /** - * Returns the number of files inside the specified path - * * @param string $folderIdentifier * @param bool $recursive - * @param array $filenameFilterCallbacks callbacks for filtering the items - * - * @return int Number of files in folder + * @param array $filterCallbacks callbacks for filtering the items */ - public function countFilesInFolder($folderIdentifier, $recursive = false, array $filenameFilterCallbacks = []) + public function countFilesInFolder($folderIdentifier, $recursive = false, array $filterCallbacks = []): int { - if (!isset($this->cachedCloudinaryResources[$folderIdentifier])) { - $this->getFilesInFolder($folderIdentifier, 0, -1, $recursive, $filenameFilterCallbacks); + $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); + + // true means we have non-core filters that has been added and we must filter on the PHP side. + if (count($filterCallbacks) > 1) { + $files = $this->getFilesInFolder($folderIdentifier, 0, 0, $recursive, $filterCallbacks); + $result = count($files); + } else { + $result = $this->getCloudinaryResourceService()->count( + $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier), + $recursive, + ); } - return count($this->cachedCloudinaryResources[$folderIdentifier]); + return $result; } /** - * Returns a list of folders inside the specified path - * * @param string $folderIdentifier * @param int $start * @param int $numberOfItems * @param bool $recursive - * @param array $folderNameFilterCallbacks callbacks for filtering the items + * @param array $filterCallbacks * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) - * - * @return array */ public function getFoldersInFolder( $folderIdentifier, $start = 0, $numberOfItems = 40, $recursive = false, - array $folderNameFilterCallbacks = [], + array $filterCallbacks = [], $sort = '', $sortRev = false - ) { - $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); + ): array + { + $parameters = (array)GeneralUtility::_GP('SET'); - if (!isset($this->cachedFolders[$folderIdentifier])) { - // Try to fetch from the cache - $this->cachedFolders[$folderIdentifier] = $this->getCache()->getCachedFolders($folderIdentifier); + $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( + $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier), + ); - // If not found in TYPO3 cache, ask Cloudinary - if (!is_array($this->cachedFolders[$folderIdentifier])) { - $this->cachedFolders[$folderIdentifier] = $this->getCloudinaryFolders($folderIdentifier); - } - } + $cloudinaryFolders = $this->getCloudinaryFolderService()->getSubFolders( + $cloudinaryFolder, + [ + 'fieldName' => 'folder', + 'direction' => isset($parameters['reverse']) && (int)$parameters['reverse'] ? 'DESC' : 'ASC', + ], + $recursive, + ); + + // Generate list of folders for the file module. + $folders = []; + foreach ($cloudinaryFolders as $cloudinaryFolder) { + $folderIdentifier = $this->getCloudinaryPathService()->computeFolderIdentifier($cloudinaryFolder['folder']); + + $result = $this->applyFilterMethodsToDirectoryItem( + $filterCallbacks, + basename($folderIdentifier), + $folderIdentifier, + dirname($folderIdentifier), + ); - // Sort - $parameters = (array) GeneralUtility::_GP('SET'); - if (isset($parameters['sort']) && $parameters['sort'] === 'file') { - (int) $parameters['reverse'] - ? krsort($this->cachedFolders[$folderIdentifier]) - : ksort($this->cachedFolders[$folderIdentifier]); + if ($result) { + $folders[] = $folderIdentifier; + } } - return $this->cachedFolders[$folderIdentifier]; + return $folders; } /** - * Returns the number of folders inside the specified path - * * @param string $folderIdentifier * @param bool $recursive - * @param array $folderNameFilterCallbacks callbacks for filtering the items - * - * @return int Number of folders in folder + * @param array $filterCallbacks */ - public function countFoldersInFolder($folderIdentifier, $recursive = false, array $folderNameFilterCallbacks = []) + public function countFoldersInFolder($folderIdentifier, $recursive = false, array $filterCallbacks = []): int { - return count($this->getFoldersInFolder($folderIdentifier, 0, -1, $recursive, $folderNameFilterCallbacks)); + // true means we have non-core filters that has been added and we must filter on the PHP side. + if (count($filterCallbacks) > 1) { + $folders = $this->getFoldersInFolder($folderIdentifier, 0, 0, $recursive, $filterCallbacks); + $result = count($folders); + } else { + $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( + $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier), + ); + + $result = $this->getCloudinaryFolderService()->countSubFolders($cloudinaryFolder, $recursive); + } + + return $result; } /** * @param string $identifier - * - * @return string */ - public function dumpFileContents($identifier) + public function dumpFileContents($identifier): string { return $this->getFileContents($identifier); } @@ -1085,10 +926,8 @@ public function dumpFileContents($identifier) * (keys r, w) of bool flags * * @param string $identifier - * - * @return array */ - public function getPermissions($identifier) + public function getPermissions($identifier): array { if (!isset($this->cachedPermissions[$identifier])) { // Cloudinary does not handle permissions @@ -1104,10 +943,8 @@ public function getPermissions($identifier) * and returns the result. * * @param int $capabilities - * - * @return int */ - public function mergeConfigurationCapabilities($capabilities) + public function mergeConfigurationCapabilities($capabilities): int { $this->capabilities &= $capabilities; return $this->capabilities; @@ -1120,13 +957,10 @@ public function mergeConfigurationCapabilities($capabilities) * * @param string $fileName Input string, typically the body of a fileName * @param string $charset Charset of the a fileName (defaults to current charset; depending on context) - * - * @return string Output string with any characters not matching [.a-zA-Z0-9_-] is substituted by '_' and trailing dots removed - * @throws Exception\InvalidFileNameException */ - public function sanitizeFileName($fileName, $charset = '') + public function sanitizeFileName($fileName, $charset = ''): string { - $fileName = $this->getCharsetConversion()->specCharsToASCII('utf-8', $fileName); + $fileName = $this->charsetConversion->specCharsToASCII('utf-8', $fileName); // Replace unwanted characters by underscores $cleanFileName = preg_replace( @@ -1141,337 +975,176 @@ public function sanitizeFileName($fileName, $charset = '') throw new InvalidFileNameException('File name "' . $fileName . '" is invalid.', 1320288991); } + $pathParts = PathUtility::pathinfo($cleanFileName); + $fileExtension = $pathParts['extension'] ?? ''; + + $cleanFileName = + str_replace('.', '_', $pathParts['filename']) . + ($fileExtension ? '.' . $fileExtension : ''); + // Handle the special jpg case which does not correspond to the file extension. return preg_replace('/jpeg$/', 'jpg', $cleanFileName); } /** - * Returns a temporary path for a given file, including the file extension. - * - * @param string $fileIdentifier + * Applies a set of filter methods to a file name to find out if it should be used or not. This is e.g. used by + * directory listings. * - * @return string + * @param array $filterMethods The filter methods to use + * @param string $itemName + * @param string $itemIdentifier + * @param string $parentIdentifier */ - protected function getTemporaryPathForFile($fileIdentifier): string + protected function applyFilterMethodsToDirectoryItem( + array $filterMethods, + $itemName, + $itemIdentifier, + $parentIdentifier + ): bool { - $temporaryFileNameAndPath = sprintf( - '%s/typo3temp/var/transient/%s%s', - Environment::getPublicPath(), - $this->storageUid, - $fileIdentifier, - ); - - $temporaryFolder = GeneralUtility::dirname($temporaryFileNameAndPath); - - if (!is_dir($temporaryFolder)) { - GeneralUtility::mkdir_deep($temporaryFolder); + foreach ($filterMethods as $filter) { + if (is_callable($filter)) { + $result = call_user_func($filter, $itemName, $itemIdentifier, $parentIdentifier, [], $this); + // We have to use -1 as the „don't include“ return value, as call_user_func() will return FALSE + // If calling the method succeeded and thus we can't use that as a return value. + if ($result === -1) { + return false; + } + if ($result === false) { + throw new RuntimeException( + 'Could not apply file/folder name filter ' . $filter[0] . '::' . $filter[1], + 1596795500, + ); + } + } } - return $temporaryFileNameAndPath; + return true; } /** - * @param string $newFileIdentifier - * - * @return bool + * We want to remove the local temporary file */ - protected function isFileIdentifier(string $newFileIdentifier): bool + protected function cleanUpTemporaryFile(string $fileIdentifier): void { - return false !== strpos($newFileIdentifier, DIRECTORY_SEPARATOR); - } + $temporaryLocalFile = CloudinaryFileUtility::getTemporaryFile($this->storageUid, $fileIdentifier); + if (is_file($temporaryLocalFile)) { + unlink($temporaryLocalFile); + } - /** - * @param string $folderIdentifier - * @param string $folderName - * - * @return string - */ - protected function canonicalizeAndCheckFolderIdentifierAndFolderName( - string $folderIdentifier, - string $folderName - ): string { - $canonicalFolderPath = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); - return $this->canonicalizeAndCheckFolderIdentifier( - $canonicalFolderPath . trim($folderName, DIRECTORY_SEPARATOR), - ); + // very coupled.... via signal slot? + $this->getExplicitDataCacheRepository()->delete($this->storageUid, $fileIdentifier); } - /** - * @param string $folderIdentifier - * - * @return array - * @throws Api\GeneralError - */ - protected function getCloudinaryFolders(string $folderIdentifier): array + public function getExplicitDataCacheRepository(): ExplicitDataCacheRepository { - $folders = []; - - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier); - - $this->log('Fetch subfolders from folder "%s"', [$cloudinaryFolder], ['getCloudinaryFolders']); - - $resources = (array) $this->getApi()->subfolders($cloudinaryFolder); - - if (!empty($resources['folders'])) { - foreach ($resources['folders'] as $cloudinaryFolder) { - $folders[] = $this->canonicalizeAndCheckFolderIdentifierAndFolderName( - $folderIdentifier, - $cloudinaryFolder['name'], - ); - } - } - - // Add result into typo3 cache to spare [API] Calls the next time... - $this->getCache()->setCachedFolders($folderIdentifier, $folders); + return GeneralUtility::makeInstance(ExplicitDataCacheRepository::class); + } - return $folders; + protected function isProcessedFile(string $identifier): bool + { + return str_starts_with($identifier, self::PROCESSEDFILE_IDENTIFIER_PREFIX); } - /** - * @param string $folderIdentifier - * - * @return array - */ - protected function getCloudinaryResources(string $folderIdentifier): array + protected function getProcessedFileUri(string $identifier): string { - $cloudinaryResources = []; - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier); - if (!$cloudinaryFolder) { - $cloudinaryFolder = self::ROOT_FOLDER_IDENTIFIER . '*'; + if (! $this->isProcessedFile($identifier)) { + throw new \DomainException('Identifier does not belong to a processed file', 1709283844258); } - // Before calling the Search API, make sure we are connected with the right cloudinary account - $this->initializeApi(); - - do { - $nextCursor = isset($response) ? $response['next_cursor'] : ''; - $this->log( - '[API][SEARCH] Cloudinary\Search() - fetch resources from folder "%s" %s', - [$cloudinaryFolder, $nextCursor ? 'and cursor ' . $nextCursor : ''], - ['getCloudinaryResources()'], - ); - - /** @var Search $search */ - $search = new Search(); - $response = $search - ->expression('folder=' . $cloudinaryFolder) - ->sort_by('public_id', 'asc') - ->max_results(500) - ->next_cursor($nextCursor) - ->execute(); - - if (is_array($response['resources'])) { - foreach ($response['resources'] as $resource) { - // Compute file identifier - $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier( - $this->getCloudinaryPathService()->computeFileIdentifier($resource), - ); - - // Compute folder identifier - #$computedFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier( - # GeneralUtility::dirname($fileIdentifier) - #); + return substr($identifier, strlen(self::PROCESSEDFILE_IDENTIFIER_PREFIX)); + } - // We manually filter the resources belonging to the given folder to handle the "root" folder case. - #if ($computedFolderIdentifier === $folderIdentifier) { - $cloudinaryResources[$fileIdentifier] = $resource; - #} - } - } - } while (!empty($response) && array_key_exists('next_cursor', $response)); + protected function isProcessedFolder(string $identifier): bool + { + $storageRecord = $this->getStorageObject()->getStorageRecord(); - // Add result into typo3 cache to spare API calls next time... - $this->getCache()->setCachedFiles($folderIdentifier, $cloudinaryResources); + // Example value for $storageRecord['processingfolder'] is "2:/_processed_" + // we want to remove the "2:" from the expression + $processedStorageFolderName = $storageRecord['processingfolder'] ?? '_processed_'; + $folderPath = preg_replace('/^[0-9]+:/', '', $processedStorageFolderName); - return $cloudinaryResources; + // We detect if the identifier start with the value from $folderPath + return str_starts_with($identifier, $folderPath); } - /** - * @param string $fileIdentifier - * - * @return array|null - */ - protected function getCloudinaryResource(string $fileIdentifier) + protected function isFileIdentifier(string $newFileIdentifier): bool { - $cloudinaryResource = null; - try { - // do a double check since we have an asynchronous mechanism. - $cloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); - $resourceType = $this->getCloudinaryPathService()->getResourceType($fileIdentifier); - $cloudinaryResource = (array) $this->getApi()->resource($cloudinaryPublicId, [ - 'resource_type' => $resourceType, - ]); - } catch (NotFound $e) { - return null; - } - return $cloudinaryResource; + return str_contains($newFileIdentifier, DIRECTORY_SEPARATOR); } - /** - * @param string $fileIdentifier - * - * @return array|false - */ - protected function getCachedCloudinaryResource(string $fileIdentifier) + protected function canonicalizeFolderIdentifierAndFolderName(string $folderIdentifier, string $folderName): string { - $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier(GeneralUtility::dirname($fileIdentifier)); - - // Warm up the cache! - if (!isset($this->cachedCloudinaryResources[$folderIdentifier][$fileIdentifier])) { - $this->getFilesInFolder($folderIdentifier, 0, -1); - } + $canonicalFolderPath = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); + return $this->canonicalizeAndCheckFolderIdentifier( + $canonicalFolderPath . trim($folderName, DIRECTORY_SEPARATOR), + ); + } - return isset($this->cachedCloudinaryResources[$folderIdentifier][$fileIdentifier]) - ? $this->cachedCloudinaryResources[$folderIdentifier][$fileIdentifier] - : false; + protected function canonicalizeFolderIdentifierAndFileName(string $folderIdentifier, string $fileName): string + { + return $this->canonicalizeAndCheckFileIdentifier( + $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier) . $fileName, + ); } - /** - * @return CloudinaryPathService - */ - protected function getCloudinaryPathService() + protected function getCloudinaryPathService(): CloudinaryPathService { if (!$this->cloudinaryPathService) { $this->cloudinaryPathService = GeneralUtility::makeInstance( CloudinaryPathService::class, - $this->configuration, + $this->storageUid + ? $this->getStorageObject() + : $this->configuration, ); } return $this->cloudinaryPathService; } - /** - * Test the connection - */ - protected function testConnection() + protected function getStorageObject(): ResourceStorage { - $messageQueue = $this->getMessageQueue(); - $localizationPrefix = $this->languageFile . ':driverConfiguration.message.'; - try { - $this->initializeApi(); - - $search = new Search(); - $search->expression('folder=' . self::ROOT_FOLDER_IDENTIFIER)->execute(); - - /** @var FlashMessage $message */ - $message = GeneralUtility::makeInstance( - FlashMessage::class, - LocalizationUtility::translate($localizationPrefix . 'connectionTestSuccessful.message'), - LocalizationUtility::translate($localizationPrefix . 'connectionTestSuccessful.title'), - FlashMessage::OK, - ); - $messageQueue->addMessage($message); - } catch (\Exception $exception) { - /** @var FlashMessage $message */ - $message = GeneralUtility::makeInstance( - FlashMessage::class, - $exception->getMessage(), - LocalizationUtility::translate($localizationPrefix . 'connectionTestFailed.title'), - FlashMessage::WARNING, - ); - $messageQueue->addMessage($message); - } + /** @var ResourceFactory $resourceFactory */ + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + return $resourceFactory->getStorageObject($this->storageUid); } - /** - * @return FlashMessageQueue - */ - protected function getMessageQueue() + protected function getCloudinaryResourceService(): CloudinaryResourceService { - /** @var FlashMessageService $flashMessageService */ - $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); - return $flashMessageService->getMessageQueueByIdentifier(); - } + if (!$this->cloudinaryResourceService) { - /** - * Checks if an object exists - * - * @param string $fileIdentifier - * - * @return bool - */ - protected function resourceExists(string $fileIdentifier) - { - // Load from cache - $cloudinaryResource = $this->getCachedCloudinaryResource($fileIdentifier); - if (empty($cloudinaryResource)) { - $cloudinaryResource = $this->getCloudinaryResource($fileIdentifier); - - // If we find a cloudinary resource we had a bit of delay. - // Cloudinary is sometimes asynchronous in the way it handles files. - // In this case, we better flush the cache... - if (!empty($cloudinaryResource)) { - $this->flushFileCache(); - } - $this->log('Resource with identifier "%s" does not (yet) exist.', [$fileIdentifier], ['resourcesExists()']); + $this->cloudinaryResourceService = GeneralUtility::makeInstance( + CloudinaryResourceService::class, + $this->getStorageObject() + ); } - return !empty($cloudinaryResource); - } - /** - * @return void - */ - protected function flushCache(): void - { - $this->flushFolderCache(); - $this->flushFileCache(); + return $this->cloudinaryResourceService; } - /** - * @return void - */ - protected function flushFileCache(): void + protected function getCloudinaryTestConnectionService(): CloudinaryTestConnectionService { - // Flush the file cache entries - $this->getCache()->flushFileCache(); - - $this->cachedCloudinaryResources = []; + return GeneralUtility::makeInstance(CloudinaryTestConnectionService::class, $this->configuration); } - /** - * @return void - */ - protected function flushFolderCache(): void + protected function getCloudinaryFolderService(): CloudinaryFolderService { - // Flush the file cache entries - $this->getCache()->flushFolderCache(); - - $this->cachedFolders = []; - } + if (!$this->cloudinaryFolderService) { + $this->cloudinaryFolderService = GeneralUtility::makeInstance( + CloudinaryFolderService::class, + $this->storageUid, + ); + } - /** - * @return void - */ - protected function initializeApi() - { - CloudinaryApiUtility::initializeByConfiguration($this->configuration); + return $this->cloudinaryFolderService; } - /** - * @return Api - */ - protected function getApi() + protected function getUploadApi(): UploadApi { - $this->initializeApi(); - - // The object \Cloudinary\Api behaves like a singleton object. - // The problem: if we have multiple driver instances / configuration, we don't get the expected result - // meaning we are wrongly fetching resources from other cloudinary "buckets" because of the singleton behaviour - // Therefore it is better to create a new instance upon each API call to avoid driver confusion - return new Api(); + return CloudinaryApiUtility::getCloudinary($this->configuration)->uploadApi(); } - /** - * @return CloudinaryTypo3Cache|object - */ - protected function getCache() + protected function getAdminApi(): AdminApi { - if ($this->cloudinaryTypo3Cache === null) { - $this->cloudinaryTypo3Cache = GeneralUtility::makeInstance( - CloudinaryTypo3Cache::class, - (int) $this->storageUid, - ); - } - return $this->cloudinaryTypo3Cache; + return CloudinaryApiUtility::getCloudinary($this->configuration)->adminApi(); } } diff --git a/Classes/Driver/CloudinaryFastDriver.php b/Classes/Driver/CloudinaryFastDriver.php deleted file mode 100644 index 5e5a49c..0000000 --- a/Classes/Driver/CloudinaryFastDriver.php +++ /dev/null @@ -1,1357 +0,0 @@ - ['r' => bool, 'w' => bool] - * - * @var array - */ - protected $cachedPermissions = []; - - /** @var ConfigurationService */ - protected $configurationService; - - /** - * @var ResourceStorage - */ - protected $storage = null; - - /** - * @var CharsetConverter - */ - protected $charsetConversion = null; - - /** - * @var CloudinaryPathService - */ - protected $cloudinaryPathService; - - /** - * @var CloudinaryResourceService - */ - protected $cloudinaryResourceService; - - /** - * @var CloudinaryFolderService - */ - protected $cloudinaryFolderService; - - /** - * @param array $configuration - */ - public function __construct(array $configuration = []) - { - $this->configuration = $configuration; - parent::__construct($configuration); - - // The capabilities default of this driver. See CAPABILITY_* constants for possible values - $this->capabilities = - ResourceStorage::CAPABILITY_BROWSABLE | - ResourceStorage::CAPABILITY_PUBLIC | - ResourceStorage::CAPABILITY_WRITABLE; - - $this->configurationService = GeneralUtility::makeInstance(ConfigurationService::class, $this->configuration); - - $this->charsetConversion = GeneralUtility::makeInstance(CharsetConverter::class); - } - - /** - * @return void - */ - public function processConfiguration() - { - } - - /** - * @return void - */ - public function initialize() - { - // Test connection if we are in the edit view of this storage - if ( - !Environment::isCli() && - ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend() && - !empty($_GET['edit']['sys_file_storage']) - ) { - $this->getCloudinaryTestConnectionService()->test(); - } - } - - /** - * @param string $identifier - * - * @return string - */ - public function getPublicUrl($identifier): string - { - // for processed file - $pattern = sprintf('/^PROCESSEDFILE\/(%s\/.*)/', $this->configurationService->get('cloudName')); - $matches = []; - if (preg_match($pattern, $identifier, $matches)) { - return 'https://res.cloudinary.com/' . $matches[1]; - } - - $cloudinaryResource = $this->getCloudinaryResourceService()->getResource( - $this->getCloudinaryPathService()->computeCloudinaryPublicId($identifier), - ); - - return $cloudinaryResource ? $cloudinaryResource['secure_url'] : ''; - } - - /** - * @param string $message - * @param array $arguments - * @param array $data - */ - protected function log(string $message, array $arguments = [], array $data = []) - { - /** @var Logger $logger */ - $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); - $logger->log(LogLevel::INFO, vsprintf($message, $arguments), $data); - } - - /** - * Creates a (cryptographic) hash for a file. - * - * @param string $fileIdentifier - * @param string $hashAlgorithm - * - * @return string - */ - public function hash($fileIdentifier, $hashAlgorithm) - { - return $this->hashIdentifier($fileIdentifier); - } - - /** - * Returns the identifier of the default folder new files should be put into. - * - * @return string - */ - public function getDefaultFolder() - { - return $this->getRootLevelFolder(); - } - - /** - * Returns the identifier of the root level folder of the storage. - * - * @return string - */ - public function getRootLevelFolder() - { - return DIRECTORY_SEPARATOR; - } - - /** - * Returns information about a file. - * - * @param string $fileIdentifier - * @param array $propertiesToExtract Array of properties which are be extracted - * If empty all will be extracted - * - * @return array - * @throws \Exception - */ - public function getFileInfoByIdentifier($fileIdentifier, array $propertiesToExtract = []) - { - $publicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); - $cloudinaryResource = $this->getCloudinaryResourceService()->getResource($publicId); - // We have a problem Hudson! - if (!$cloudinaryResource) { - throw new \Exception( - 'I could not find a corresponding cloudinary resource for file ' . $fileIdentifier, - 1591775048, - ); - } - - $mimeType = $this->getCloudinaryPathService()->guessMimeType($cloudinaryResource); - if (!$mimeType) { - $this->log( - 'Just a notice! Time consuming action ahead. I am going to download a file "%s"', - [$fileIdentifier], - ['getFileInfoByIdentifier'], - ); - - // We are force to download the file in order to correctly find the mime type. - $localFile = $this->getFileForLocalProcessing($fileIdentifier); - - /** @var FileInfo $fileInfo */ - $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $localFile); - - $mimeType = $fileInfo->getMimeType(); - } - - return [ - 'identifier_hash' => $this->hashIdentifier($fileIdentifier), - 'folder_hash' => sha1($this->canonicalizeAndCheckFolderIdentifier(PathUtility::dirname($fileIdentifier))), - 'creation_date' => strtotime($cloudinaryResource['created_at']), - 'modification_date' => strtotime($cloudinaryResource['created_at']), - 'mime_type' => $mimeType, - 'extension' => $this->getResourceInfo($cloudinaryResource, 'format'), - 'size' => $this->getResourceInfo($cloudinaryResource, 'bytes'), - 'width' => $this->getResourceInfo($cloudinaryResource, 'width'), - 'height' => $this->getResourceInfo($cloudinaryResource, 'height'), - 'storage' => $this->storageUid, - 'identifier' => $fileIdentifier, - 'name' => PathUtility::basename($fileIdentifier), - ]; - } - - /** - * @param array $resource - * @param string $name - * - * @return string - */ - protected function getResourceInfo(array $resource, string $name): string - { - return isset($resource[$name]) ? $resource[$name] : ''; - } - - /** - * Checks if a file exists - * - * @param string $fileIdentifier - * - * @return bool - */ - public function fileExists($fileIdentifier) - { - $cloudinaryResource = $this->getCloudinaryResourceService()->getResource( - $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier), - ); - - return !empty($cloudinaryResource); - } - - /** - * Checks if a folder exists - * - * @param string $folderIdentifier - * - * @return bool - */ - public function folderExists($folderIdentifier) - { - if ($folderIdentifier === self::ROOT_FOLDER_IDENTIFIER) { - return true; - } - $cloudinaryFolder = $this->getCloudinaryFolderService()->getFolder( - $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier), - ); - return !empty($cloudinaryFolder); - } - - /** - * @param string $fileName - * @param string $folderIdentifier - * - * @return bool - */ - public function fileExistsInFolder($fileName, $folderIdentifier) - { - $fileIdentifier = $this->canonicalizeFolderIdentifierAndFileName($folderIdentifier, $fileName); - - return $this->fileExists($fileIdentifier); - } - - /** - * Checks if a folder exists inside a storage folder - * - * @param string $folderName - * @param string $folderIdentifier - * - * @return bool - */ - public function folderExistsInFolder($folderName, $folderIdentifier) - { - return $this->folderExists($this->canonicalizeFolderIdentifierAndFolderName($folderIdentifier, $folderName)); - } - - /** - * Returns the Identifier for a folder within a given folder. - * - * @param string $folderName The name of the target folder - * @param string $folderIdentifier - * - * @return string - */ - public function getFolderInFolder($folderName, $folderIdentifier) - { - return $folderIdentifier . DIRECTORY_SEPARATOR . $folderName; - } - - /** - * @param string $localFilePath - * @param string $targetFolderIdentifier - * @param string $newFileName optional, if not given original name is used - * @param bool $removeOriginal if set the original file will be removed - * after successful operation - * - * @return string the identifier of the new file - * @throws \Exception - */ - public function addFile($localFilePath, $targetFolderIdentifier, $newFileName = '', $removeOriginal = true) - { - $fileName = $this->sanitizeFileName($newFileName !== '' ? $newFileName : PathUtility::basename($localFilePath)); - - $fileIdentifier = $this->canonicalizeFolderIdentifierAndFileName($targetFolderIdentifier, $fileName); - - // We remove a possible existing transient file to avoid bad surprise. - $this->cleanUpTemporaryFile($fileIdentifier); - - // We compute the cloudinary public id - $cloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); - - $this->log( - '[API][UPLOAD] Cloudinary\Uploader::upload() - add resource "%s"', - [$cloudinaryPublicId], - ['addFile()'], - ); - - // Before calling API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - // Upload the file - $cloudinaryResource = Uploader::upload($localFilePath, [ - 'public_id' => PathUtility::basename($cloudinaryPublicId), - 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath($targetFolderIdentifier), - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - 'overwrite' => true, - ]); - - $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); - - // We persist the uploaded resource. - $this->getCloudinaryResourceService()->save($cloudinaryResource); - - return $fileIdentifier; - } - - /** - * @param string $fileIdentifier - * @param string $targetFolderIdentifier - * @param string $newFileName - * - * @return string - */ - public function moveFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $newFileName) - { - $targetIdentifier = $targetFolderIdentifier . $newFileName; - return $this->renameFile($fileIdentifier, $targetIdentifier); - } - - /** - * Copies a file *within* the current storage. - * Note that this is only about an inner storage copy action, - * where a file is just copied to another folder in the same storage. - * - * @param string $fileIdentifier - * @param string $targetFolderIdentifier - * @param string $fileName - * - * @return string the Identifier of the new file - */ - public function copyFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $fileName) - { - $targetFileIdentifier = $this->canonicalizeFolderIdentifierAndFileName($targetFolderIdentifier, $fileName); - - // Before calling API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - $cloudinaryResource = Uploader::upload($this->getPublicUrl($fileIdentifier), [ - 'public_id' => PathUtility::basename( - $this->getCloudinaryPathService()->computeCloudinaryPublicId($targetFileIdentifier), - ), - 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath($targetFolderIdentifier), - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - 'overwrite' => true, - ]); - - $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); - - // We persist the uploaded resource - $this->getCloudinaryResourceService()->save($cloudinaryResource); - - return $targetFileIdentifier; - } - - /** - * Replaces a file with file in local file system. - * - * @param string $fileIdentifier - * @param string $localFilePath - * - * @return bool - */ - public function replaceFile($fileIdentifier, $localFilePath) - { - // We remove a possible existing transient file to avoid bad surprise. - $this->cleanUpTemporaryFile($fileIdentifier); - - $cloudinaryPublicId = PathUtility::basename( - $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier), - ); - - // Before calling the API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - // Upload the file - $cloudinaryResource = Uploader::upload($localFilePath, [ - 'public_id' => PathUtility::basename($cloudinaryPublicId), - 'folder' => $this->getCloudinaryPathService()->computeCloudinaryFolderPath( - PathUtility::dirname($fileIdentifier), - ), - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - 'overwrite' => true, - ]); - - $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); - - // We persist the uploaded resource. - $this->getCloudinaryResourceService()->save($cloudinaryResource); - - return true; - } - - /** - * Removes a file from the filesystem. This does not check if the file is - * still used or if it is a bad idea to delete it for some other reason - * this has to be taken care of in the upper layers (e.g. the Storage)! - * - * @param string $fileIdentifier - * - * @return bool TRUE if deleting the file succeeded - */ - public function deleteFile($fileIdentifier) - { - $cloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); - $this->log( - '[API][DELETE] Cloudinary\Api::delete_resources - delete resource "%s"', - [$cloudinaryPublicId], - ['deleteFile'], - ); - - $response = $this->getApi()->delete_resources($cloudinaryPublicId, [ - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - ]); - - $isDeleted = false; - - foreach ($response['deleted'] as $publicId => $status) { - if ($status === 'deleted') { - $isDeleted = (bool) $this->getCloudinaryResourceService()->delete($publicId); - } - } - - return $isDeleted; - } - - /** - * Removes a folder in filesystem. - * - * @param string $folderIdentifier - * @param bool $deleteRecursively - * - * @return bool - * @throws Api\GeneralError - */ - public function deleteFolder($folderIdentifier, $deleteRecursively = false) - { - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier); - - if ($deleteRecursively) { - $this->log( - '[API][DELETE] Cloudinary\Api::delete_resources_by_prefix() - folder "%s"', - [$cloudinaryFolder], - ['deleteFolder'], - ); - $response = $this->getApi()->delete_resources_by_prefix($cloudinaryFolder); - - foreach ($response['deleted'] as $publicId => $status) { - if ($status === 'deleted') { - $this->getCloudinaryResourceService()->delete($publicId); - } - } - } - - // We make sure the folder exists first. It will also delete sub-folder if those ones are empty. - if ($this->folderExists($folderIdentifier)) { - $this->log( - '[API][DELETE] Cloudinary\Api::delete_folder() - folder "%s"', - [$cloudinaryFolder], - ['deleteFolder'], - ); - $response = $this->getApi()->delete_folder($cloudinaryFolder); - - foreach ($response['deleted'] as $folder) { - $this->getCloudinaryFolderService()->delete($folder); - } - } - - return true; - } - - /** - * @param string $fileIdentifier - * @param bool $writable - * - * @return string - */ - public function getFileForLocalProcessing($fileIdentifier, $writable = true) - { - $temporaryPath = $this->getTemporaryPathForFile($fileIdentifier); - - if (!is_file($temporaryPath) || !filesize($temporaryPath)) { - $this->log( - '[SLOW] Downloading for local processing "%s"', - [$fileIdentifier], - ['getFileForLocalProcessing'], - ); - - file_put_contents($temporaryPath, file_get_contents($this->getPublicUrl($fileIdentifier))); - $this->log('File downloaded into "%s"', [$temporaryPath], ['getFileForLocalProcessing']); - } - - return $temporaryPath; - } - - /** - * Creates a new (empty) file and returns the identifier. - * - * @param string $fileName - * @param string $parentFolderIdentifier - * - * @return string - */ - public function createFile($fileName, $parentFolderIdentifier) - { - throw new RuntimeException( - 'createFile: not implemented action! Cloudinary Driver is limited to images.', - 1570728107, - ); - } - - /** - * Creates a folder, within a parent folder. - * If no parent folder is given, a root level folder will be created - * - * @param string $newFolderName - * @param string $parentFolderIdentifier - * @param bool $recursive - * - * @return string the Identifier of the new folder - */ - public function createFolder($newFolderName, $parentFolderIdentifier = '', $recursive = false) - { - $canonicalFolderPath = $this->canonicalizeFolderIdentifierAndFolderName( - $parentFolderIdentifier, - $newFolderName, - ); - $cloudinaryFolder = $this->getCloudinaryPathService()->normalizeCloudinaryPath($canonicalFolderPath); - - $this->log('[API][CREATE] Cloudinary\Api::createFolder() - folder "%s"', [$cloudinaryFolder], ['createFolder']); - $response = $this->getApi()->create_folder($cloudinaryFolder); - - if (!$response['success']) { - throw new \Exception('Folder creation failed: ' . $cloudinaryFolder, 1591775050); - } - $this->getCloudinaryFolderService()->save($cloudinaryFolder); - - return $canonicalFolderPath; - } - - /** - * @param string $fileIdentifier - * - * @return string - */ - public function getFileContents($fileIdentifier) - { - // Will download the file to be faster next time the content is required. - $localFileNameAndPath = $this->getFileForLocalProcessing($fileIdentifier); - return file_get_contents($localFileNameAndPath); - } - - /** - * Sets the contents of a file to the specified value. - * - * @param string $fileIdentifier - * @param string $contents - * - * @return int - */ - public function setFileContents($fileIdentifier, $contents) - { - throw new RuntimeException('setFileContents: not implemented action!', 1570728106); - } - - /** - * Renames a file in this storage. - * - * @param string $fileIdentifier - * @param string $newFileIdentifier The target path (including the file name!) - * - * @return string The identifier of the file after renaming - */ - public function renameFile($fileIdentifier, $newFileIdentifier) - { - if (!$this->isFileIdentifier($newFileIdentifier)) { - $sanitizedFileName = $this->sanitizeFileName(PathUtility::basename($newFileIdentifier)); - $folderPath = PathUtility::dirname($fileIdentifier); - $newFileIdentifier = $this->canonicalizeAndCheckFileIdentifier( - $this->canonicalizeAndCheckFolderIdentifier($folderPath) . $sanitizedFileName, - ); - } - - $cloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($fileIdentifier); - $newCloudinaryPublicId = $this->getCloudinaryPathService()->computeCloudinaryPublicId($newFileIdentifier); - - if ($cloudinaryPublicId !== $newCloudinaryPublicId) { - // Before calling API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - // Rename the file - $cloudinaryResource = Uploader::rename($cloudinaryPublicId, $newCloudinaryPublicId, [ - 'resource_type' => $this->getCloudinaryPathService()->getResourceType($fileIdentifier), - 'overwrite' => true, - ]); - - $this->checkCloudinaryUploadStatus($cloudinaryResource, $fileIdentifier); - - // We remove the old public id - $this->getCloudinaryResourceService()->delete($cloudinaryPublicId); - - // ... and insert the new cloudinary resource - $this->getCloudinaryResourceService()->save($cloudinaryResource); - } - - return $newFileIdentifier; - } - - /** - * @param array $cloudinaryResource - * @param string $fileIdentifier - * - * @throws Api\GeneralError - */ - protected function checkCloudinaryUploadStatus(array $cloudinaryResource, $fileIdentifier): void - { - if (!$cloudinaryResource && $cloudinaryResource['type'] !== 'upload') { - throw new RuntimeException('Cloudinary upload failed for ' . $fileIdentifier, 1591954950); - } - } - - /** - * Renames a folder in this storage. - * - * @param string $folderIdentifier - * @param string $newFolderName - * - * @return array A map of old to new file identifiers of all affected resources - */ - public function renameFolder($folderIdentifier, $newFolderName) - { - $renamedFiles = []; - - $pathSegments = GeneralUtility::trimExplode('/', $folderIdentifier); - $numberOfSegments = count($pathSegments); - - if ($numberOfSegments > 1) { - // Replace last folder name by the new folder name - $pathSegments[$numberOfSegments - 2] = $newFolderName; - $newFolderIdentifier = implode('/', $pathSegments); - - // Before calling the API, make sure we are connected with the right "bucket" - $this->initializeApi(); - - $renamedFiles[$folderIdentifier] = $newFolderIdentifier; - - foreach ($this->getFilesInFolder($folderIdentifier, 0, -1, true) as $oldFileIdentifier) { - $newFileIdentifier = str_replace($folderIdentifier, $newFolderIdentifier, $oldFileIdentifier); - - if ($oldFileIdentifier !== $newFileIdentifier) { - $renamedFiles[$oldFileIdentifier] = $this->renameFile($oldFileIdentifier, $newFileIdentifier); - } - } - - // After working so hard, delete the old empty folder. - $this->deleteFolder($folderIdentifier); - } - - return $renamedFiles; - } - - /** - * @param string $sourceFolderIdentifier - * @param string $targetFolderIdentifier - * @param string $newFolderName - * - * @return array All files which are affected, map of old => new file identifiers - */ - public function moveFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName) - { - // Compute the new folder identifier and then create it. - $newTargetFolderIdentifier = $this->canonicalizeFolderIdentifierAndFolderName( - $targetFolderIdentifier, - $newFolderName, - ); - - if (!$this->folderExists($newTargetFolderIdentifier)) { - $this->createFolder($newTargetFolderIdentifier); - } - - $movedFiles = []; - $files = $this->getFilesInFolder($sourceFolderIdentifier, 0, -1); - foreach ($files as $fileIdentifier) { - $movedFiles[$fileIdentifier] = $this->moveFileWithinStorage( - $fileIdentifier, - $newTargetFolderIdentifier, - PathUtility::basename($fileIdentifier), - ); - } - - // Delete the old and empty folder - $this->deleteFolder($sourceFolderIdentifier); - - return $movedFiles; - } - - /** - * @param string $sourceFolderIdentifier - * @param string $targetFolderIdentifier - * @param string $newFolderName - * - * @return bool - */ - public function copyFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName) - { - // Compute the new folder identifier and then create it. - $newTargetFolderIdentifier = $this->canonicalizeFolderIdentifierAndFolderName( - $targetFolderIdentifier, - $newFolderName, - ); - - if (!$this->folderExists($newTargetFolderIdentifier)) { - $this->createFolder($newTargetFolderIdentifier); - } - - $files = $this->getFilesInFolder($sourceFolderIdentifier, 0, -1, true); - foreach ($files as $fileIdentifier) { - $newFileIdentifier = str_replace($sourceFolderIdentifier, $newTargetFolderIdentifier, $fileIdentifier); - - $this->copyFileWithinStorage( - $fileIdentifier, - GeneralUtility::dirname($newFileIdentifier), - PathUtility::basename($fileIdentifier), - ); - } - - return true; - } - - /** - * Checks if a folder contains files and (if supported) other folders. - * - * @param string $folderIdentifier - * - * @return bool TRUE if there are no files and folders within $folder - */ - public function isFolderEmpty($folderIdentifier) - { - return $this->getCloudinaryFolderService()->countSubFolders($folderIdentifier); - } - - /** - * Checks if a given identifier is within a container, e.g. if - * a file or folder is within another folder. - * - * Hint: this also needs to return TRUE if the given identifier - * matches the container identifier to allow access to the root - * folder of a filemount. - * - * @param string $folderIdentifier - * @param string $identifier identifier to be checked against $folderIdentifier - * - * @return bool TRUE if $content is within or matches $folderIdentifier - */ - public function isWithin($folderIdentifier, $identifier) - { - $folderIdentifier = $this->canonicalizeAndCheckFileIdentifier($folderIdentifier); - $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier($identifier); - if ($folderIdentifier === $fileIdentifier) { - return true; - } - - // File identifier canonicalization will not modify a single slash so - // we must not append another slash in that case. - if ($folderIdentifier !== DIRECTORY_SEPARATOR) { - $folderIdentifier .= DIRECTORY_SEPARATOR; - } - - return \str_starts_with($fileIdentifier, $folderIdentifier); - } - - /** - * Returns information about a file. - * - * @param string $folderIdentifier - * - * @return array - */ - public function getFolderInfoByIdentifier($folderIdentifier) - { - $canonicalFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); - return [ - 'identifier' => $canonicalFolderIdentifier, - 'name' => PathUtility::basename( - $this->getCloudinaryPathService()->normalizeCloudinaryPath($canonicalFolderIdentifier), - ), - 'storage' => $this->storageUid, - ]; - } - - /** - * Returns a file inside the specified path - * - * @param string $fileName - * @param string $folderIdentifier - * - * @return string - */ - public function getFileInFolder($fileName, $folderIdentifier) - { - $folderIdentifier = $folderIdentifier . DIRECTORY_SEPARATOR . $fileName; - return $folderIdentifier; - } - - /** - * Returns a list of files inside the specified path - * - * @param string $folderIdentifier - * @param int $start - * @param int $numberOfItems - * @param bool $recursive - * @param array $filterCallbacks callbacks for filtering the items - * @param string $sort Property name used to sort the items. - * Among them may be: '' (empty, no sorting), name, - * fileext, size, tstamp and rw. - * If a driver does not support the given property, it - * should fall back to "name". - * @param bool $sortRev TRUE to indicate reverse sorting (last to first) - * - * @return array of FileIdentifiers - */ - public function getFilesInFolder( - $folderIdentifier, - $start = 0, - $numberOfItems = 40, - $recursive = false, - array $filterCallbacks = [], - $sort = '', - $sortRev = false - ) { - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( - $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier), - ); - - // Set default orderings - $parameters = (array) GeneralUtility::_GP('SET'); - if ($parameters['sort'] === 'file') { - $parameters['sort'] = 'filename'; - } elseif ($parameters['sort'] === 'tstamp') { - $parameters['sort'] = 'created_at'; - } else { - $parameters['sort'] = 'filename'; - $parameters['reverse'] = 'ASC'; - } - - $orderings = [ - 'fieldName' => $parameters['sort'], - 'direction' => isset($parameters['reverse']) && (int) $parameters['reverse'] ? 'DESC' : 'ASC', - ]; - - $pagination = [ - 'maxResult' => $numberOfItems, - 'firstResult' => (int) GeneralUtility::_GP('pointer'), - ]; - - $cloudinaryResources = $this->getCloudinaryResourceService()->getResources( - $cloudinaryFolder, - $orderings, - $pagination, - $recursive, - ); - - // Generate list of folders for the file module. - $files = []; - foreach ($cloudinaryResources as $cloudinaryResource) { - // Compute file identifier - $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier( - $this->getCloudinaryPathService()->computeFileIdentifier($cloudinaryResource), - ); - - $result = $this->applyFilterMethodsToDirectoryItem( - $filterCallbacks, - basename($fileIdentifier), - $fileIdentifier, - dirname($fileIdentifier), - ); - - if ($result) { - $files[] = $fileIdentifier; - } - } - - return $files; - } - - /** - * Returns the number of files inside the specified path - * - * @param string $folderIdentifier - * @param bool $recursive - * @param array $filterCallbacks callbacks for filtering the items - * - * @return int - */ - public function countFilesInFolder($folderIdentifier, $recursive = false, array $filterCallbacks = []) - { - $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); - - // true means we have non-core filters that has been added and we must filter on the PHP side. - if (count($filterCallbacks) > 1) { - $files = $this->getFilesInFolder($folderIdentifier, 0, 0, $recursive, $filterCallbacks); - $result = count($files); - } else { - $result = $this->getCloudinaryResourceService()->count( - $this->getCloudinaryPathService()->computeCloudinaryFolderPath($folderIdentifier), - $recursive, - ); - } - return $result; - } - - /** - * Returns a list of folders inside the specified path - * - * @param string $folderIdentifier - * @param int $start - * @param int $numberOfItems - * @param bool $recursive - * @param array $filterCallbacks - * @param string $sort Property name used to sort the items. - * Among them may be: '' (empty, no sorting), name, - * fileext, size, tstamp and rw. - * If a driver does not support the given property, it - * should fall back to "name". - * @param bool $sortRev TRUE to indicate reverse sorting (last to first) - * - * @return array - */ - public function getFoldersInFolder( - $folderIdentifier, - $start = 0, - $numberOfItems = 40, - $recursive = false, - array $filterCallbacks = [], - $sort = '', - $sortRev = false - ) { - $parameters = (array) GeneralUtility::_GP('SET'); - - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( - $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier), - ); - - $cloudinaryFolders = $this->getCloudinaryFolderService()->getSubFolders( - $cloudinaryFolder, - [ - 'fieldName' => 'folder', - 'direction' => isset($parameters['reverse']) && (int) $parameters['reverse'] ? 'DESC' : 'ASC', - ], - $recursive, - ); - - // Generate list of folders for the file module. - $folders = []; - foreach ($cloudinaryFolders as $cloudinaryFolder) { - $folderIdentifier = $this->getCloudinaryPathService()->computeFolderIdentifier($cloudinaryFolder['folder']); - - $result = $this->applyFilterMethodsToDirectoryItem( - $filterCallbacks, - basename($folderIdentifier), - $folderIdentifier, - dirname($folderIdentifier), - ); - - if ($result) { - $folders[] = $folderIdentifier; - } - } - - return $folders; - } - - /** - * Returns the number of folders inside the specified path - * - * @param string $folderIdentifier - * @param bool $recursive - * @param array $filterCallbacks - * - * @return int - */ - public function countFoldersInFolder($folderIdentifier, $recursive = false, array $filterCallbacks = []) - { - // true means we have non-core filters that has been added and we must filter on the PHP side. - if (count($filterCallbacks) > 1) { - $folders = $this->getFoldersInFolder($folderIdentifier, 0, 0, $recursive, $filterCallbacks); - $result = count($folders); - } else { - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath( - $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier), - ); - - $result = $this->getCloudinaryFolderService()->countSubFolders($cloudinaryFolder, $recursive); - } - - return $result; - } - - /** - * @param string $identifier - * - * @return string - */ - public function dumpFileContents($identifier) - { - return $this->getFileContents($identifier); - } - - /** - * Returns the permissions of a file/folder as an array - * (keys r, w) of bool flags - * - * @param string $identifier - * - * @return array - */ - public function getPermissions($identifier) - { - if (!isset($this->cachedPermissions[$identifier])) { - // Cloudinary does not handle permissions - $permissions = ['r' => true, 'w' => true]; - $this->cachedPermissions[$identifier] = $permissions; - } - return $this->cachedPermissions[$identifier]; - } - - /** - * Merges the capabilites merged by the user at the storage - * configuration into the actual capabilities of the driver - * and returns the result. - * - * @param int $capabilities - * - * @return int - */ - public function mergeConfigurationCapabilities($capabilities) - { - $this->capabilities &= $capabilities; - return $this->capabilities; - } - - /** - * Returns a string where any character not matching [.a-zA-Z0-9_-] is - * substituted by '_' - * Trailing dots are removed - * - * @param string $fileName Input string, typically the body of a fileName - * @param string $charset Charset of the a fileName (defaults to current charset; depending on context) - * - * @return string Output string with any characters not matching [.a-zA-Z0-9_-] is substituted by '_' and trailing dots removed - * @throws Exception\InvalidFileNameException - */ - public function sanitizeFileName($fileName, $charset = '') - { - $fileName = $this->charsetConversion->specCharsToASCII('utf-8', $fileName); - - // Replace unwanted characters by underscores - $cleanFileName = preg_replace( - '/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', - '_', - trim($fileName), - ); - - // Strip trailing dots and return - $cleanFileName = rtrim($cleanFileName, '.'); - if ($cleanFileName === '') { - throw new InvalidFileNameException('File name "' . $fileName . '" is invalid.', 1320288991); - } - - $pathParts = PathUtility::pathinfo($cleanFileName); - - $cleanFileName = - str_replace('.', '_', $pathParts['filename']) . - ($pathParts['extension'] ? '.' . $pathParts['extension'] : ''); - - // Handle the special jpg case which does not correspond to the file extension. - return preg_replace('/jpeg$/', 'jpg', $cleanFileName); - } - - /** - * Applies a set of filter methods to a file name to find out if it should be used or not. This is e.g. used by - * directory listings. - * - * @param array $filterMethods The filter methods to use - * @param string $itemName - * @param string $itemIdentifier - * @param string $parentIdentifier - * - * @return bool - * @throws \RuntimeException - */ - protected function applyFilterMethodsToDirectoryItem( - array $filterMethods, - $itemName, - $itemIdentifier, - $parentIdentifier - ) { - foreach ($filterMethods as $filter) { - if (is_callable($filter)) { - $result = call_user_func($filter, $itemName, $itemIdentifier, $parentIdentifier, [], $this); - // We have to use -1 as the „don't include“ return value, as call_user_func() will return FALSE - // If calling the method succeeded and thus we can't use that as a return value. - if ($result === -1) { - return false; - } - if ($result === false) { - throw new \RuntimeException( - 'Could not apply file/folder name filter ' . $filter[0] . '::' . $filter[1], - 1596795500, - ); - } - } - } - return true; - } - - /** - * Returns a temporary path for a given file, including the file extension. - * - * @param string $fileIdentifier - * - * @return string - */ - protected function getTemporaryPathForFile($fileIdentifier): string - { - $temporaryFileNameAndPath = - Environment::getPublicPath() . - DIRECTORY_SEPARATOR . - 'typo3temp/var/transient/' . - $this->storageUid . - $fileIdentifier; - - $temporaryFolder = GeneralUtility::dirname($temporaryFileNameAndPath); - - if (!is_dir($temporaryFolder)) { - GeneralUtility::mkdir_deep($temporaryFolder); - } - return $temporaryFileNameAndPath; - } - - /** - * We want to remove the local temporary file - */ - protected function cleanUpTemporaryFile(string $fileIdentifier): void - { - $temporaryLocalFile = $this->getTemporaryPathForFile($fileIdentifier); - if (is_file($temporaryLocalFile)) { - unlink($temporaryLocalFile); - } - - // very coupled.... via signal slot? - $this->getExplicitDataCacheRepository()->delete($this->storageUid, $fileIdentifier); - } - - /** - * @return object|ExplicitDataCacheRepository - */ - public function getExplicitDataCacheRepository() - { - return GeneralUtility::makeInstance(ExplicitDataCacheRepository::class); - } - - /** - * @param string $newFileIdentifier - * - * @return bool - */ - protected function isFileIdentifier(string $newFileIdentifier): bool - { - return false !== strpos($newFileIdentifier, DIRECTORY_SEPARATOR); - } - - /** - * @param string $folderIdentifier - * @param string $folderName - * - * @return string - */ - protected function canonicalizeFolderIdentifierAndFolderName(string $folderIdentifier, string $folderName): string - { - $canonicalFolderPath = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); - return $this->canonicalizeAndCheckFolderIdentifier( - $canonicalFolderPath . trim($folderName, DIRECTORY_SEPARATOR), - ); - } - - /** - * @param string $folderIdentifier - * @param string $fileName - * - * @return string - */ - protected function canonicalizeFolderIdentifierAndFileName(string $folderIdentifier, string $fileName): string - { - return $this->canonicalizeAndCheckFileIdentifier( - $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier) . $fileName, - ); - } - - /** - * @return object|CloudinaryPathService - */ - protected function getCloudinaryPathService() - { - if (!$this->cloudinaryPathService) { - $this->cloudinaryPathService = GeneralUtility::makeInstance( - CloudinaryPathService::class, - $this->configuration, - ); - } - - return $this->cloudinaryPathService; - } - - /** - * @return CloudinaryResourceService - */ - protected function getCloudinaryResourceService() - { - if (!$this->cloudinaryResourceService) { - /** @var ResourceFactory $resourceFactory */ - $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); - - $this->cloudinaryResourceService = GeneralUtility::makeInstance( - CloudinaryResourceService::class, - $resourceFactory->getStorageObject($this->storageUid), - ); - } - - return $this->cloudinaryResourceService; - } - - /** - * @return object|CloudinaryTestConnectionService - */ - protected function getCloudinaryTestConnectionService() - { - return GeneralUtility::makeInstance(CloudinaryTestConnectionService::class, $this->configuration); - } - - /** - * @return CloudinaryFolderService - */ - protected function getCloudinaryFolderService() - { - if (!$this->cloudinaryFolderService) { - $this->cloudinaryFolderService = GeneralUtility::makeInstance( - CloudinaryFolderService::class, - $this->storageUid, - ); - } - - return $this->cloudinaryFolderService; - } - - /** - * @return void - */ - protected function initializeApi() - { - Cloudinary::config([ - 'cloud_name' => $this->configurationService->get('cloudName'), - 'api_key' => $this->configurationService->get('apiKey'), - 'api_secret' => $this->configurationService->get('apiSecret'), - 'timeout' => $this->configurationService->get('timeout'), - 'secure' => true, - ]); - } - - /** - * @return Api - */ - protected function getApi() - { - $this->initializeApi(); - - // The object \Cloudinary\Api behaves like a singleton object. - // The problem: if we have multiple driver instances / configuration, we don't get the expected result - // meaning we are wrongly fetching resources from other cloudinary "buckets" because of the singleton behaviour - // Therefore it is better to create a new instance upon each API call to avoid driver confusion - return new Api(); - } -} diff --git a/Classes/EventHandlers/BeforeFileProcessingEventHandler.php b/Classes/EventHandlers/BeforeFileProcessingEventHandler.php new file mode 100644 index 0000000..c020369 --- /dev/null +++ b/Classes/EventHandlers/BeforeFileProcessingEventHandler.php @@ -0,0 +1,109 @@ +getDriver(); + if (!$driver instanceof CloudinaryDriver) { + return; + } + + $processedFile = $event->getProcessedFile(); + if(! in_array($processedFile->getTaskIdentifier(), self::ALLOWED_TASKS)) { + return; + } + if ($processedFile->isProcessed()) { + return; + } + if (str_starts_with($processedFile->getIdentifier(), 'PROCESSEDFILE')) { + return; + } + + /** @var File $file */ + $file = $event->getFile(); + + try { + $transformations = $this->processingInstructionConverter->convertProcessingConfiguration($event->getProcessedFile()->getTask()); + } catch (NoCloudinaryTransformation $e) { + // Cloudinary is unable to do the required processing + return; + } + + if ($file->getType() === $file::FILETYPE_VIDEO) { + $firstTransformation = $transformations[0]; + if ($firstTransformation instanceof BaseAction) { + $firstTransformation->addQualifiers(new Format('png')); + } + } + + $transformations[] = [ + 'fetch_format' => 'auto', + 'quality' => 'auto:eco', + ]; + + $explicitData = $this->getCloudinaryImageService()->getExplicitData($file, [ + 'type' => 'upload', + 'eager' => [$transformations], + ]); + + $url = $explicitData['eager'][0]['secure_url'] ?? null; + if (!isset($url)) { + // cloudinary is unable to render + return; + } + + $publicBaseUrl = $driver->getPublicBaseUrl(); + if (! str_starts_with($url, $publicBaseUrl)) { + throw new InvalidResourceUrlException($url, $publicBaseUrl, 1709284880259); + } + $identifier = CloudinaryDriver::PROCESSEDFILE_IDENTIFIER_PREFIX . substr($url, strlen($publicBaseUrl)); + + $processedFile->setName(basename($url)); + $processedFile->setIdentifier($identifier); + + $processedFile->updateProperties([ + 'width' => $explicitData['eager'][0]['width'], + 'height' => $explicitData['eager'][0]['height'], + ]); + + $processedFileRepository = GeneralUtility::makeInstance(ProcessedFileRepository::class); + $processedFileRepository->add($processedFile); + } + + public function getCloudinaryImageService(): CloudinaryImageService + { + return GeneralUtility::makeInstance(CloudinaryImageService::class); + } +} diff --git a/Classes/Events/ClearCachePageEvent.php b/Classes/Events/ClearCachePageEvent.php new file mode 100644 index 0000000..24a347c --- /dev/null +++ b/Classes/Events/ClearCachePageEvent.php @@ -0,0 +1,32 @@ +tags = $tags; + } + + public function getTags(): array + { + return $this->tags; + } + + public function setTags(array $tags): ClearCachePageEvent + { + $this->tags = $tags; + return $this; + } + +} diff --git a/Classes/Exceptions/CloudinaryNotFoundException.php b/Classes/Exceptions/CloudinaryNotFoundException.php new file mode 100644 index 0000000..29e640f --- /dev/null +++ b/Classes/Exceptions/CloudinaryNotFoundException.php @@ -0,0 +1,16 @@ +getStorage()->getDriverType() !== CloudinaryFastDriver::DRIVER_TYPE) { + if ($file->getStorage()->getDriverType() !== CloudinaryDriver::DRIVER_TYPE) { return; } $cloudinaryImageService = GeneralUtility::makeInstance(CloudinaryImageService::class); diff --git a/Classes/Services/AbstractCloudinaryMediaService.php b/Classes/Services/AbstractCloudinaryMediaService.php index fcbf0ae..fb19555 100644 --- a/Classes/Services/AbstractCloudinaryMediaService.php +++ b/Classes/Services/AbstractCloudinaryMediaService.php @@ -2,7 +2,7 @@ namespace Visol\Cloudinary\Services; -use Cloudinary\Uploader; +use Cloudinary\Api\Upload\UploadApi; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use TYPO3\CMS\Core\Log\Logger; use TYPO3\CMS\Core\Log\LogLevel; @@ -10,35 +10,11 @@ use TYPO3\CMS\Core\Resource\File; use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; -use Visol\Cloudinary\Driver\CloudinaryDriver; use Visol\Cloudinary\Utility\CloudinaryApiUtility; abstract class AbstractCloudinaryMediaService { - /** - * @throws \Exception - */ - protected function initializeApi(ResourceStorage $storage): void - { - // Check the file is stored on the right storage - // If not we should trigger an exception - if ($storage->getDriverType() !== CloudinaryDriver::DRIVER_TYPE) { - $message = sprintf( - 'Wrong storage! Can not initialize with storage type "%s".', - $storage->getDriverType() - ); - throw new \Exception($message, 1590401459); - } - - CloudinaryApiUtility::initializeByConfiguration($storage->getConfiguration()); - } - /** - * @param File $file - * @param array $options - * - * @return array - */ public function getExplicitData(File $file, array $options): array { $publicId = $this->getPublicIdForFile($file); @@ -46,8 +22,7 @@ public function getExplicitData(File $file, array $options): array $explicitData = $this->explicitDataCacheRepository->findByStorageAndPublicIdAndOptions($file->getStorage()->getUid(), $publicId, $options)['explicit_data']; if (!$explicitData) { - $this->initializeApi($file->getStorage()); - $explicitData = Uploader::explicit($publicId, $options); + $explicitData = $this->getUploadApi($file->getStorage())->explicit($publicId, $options); try { $this->explicitDataCacheRepository->save($file->getStorage()->getUid(), $publicId, $options, $explicitData); } catch (UniqueConstraintViolationException $e) { @@ -58,12 +33,7 @@ public function getExplicitData(File $file, array $options): array return $explicitData; } - /** - * @param string $message - * @param array $arguments - * @param array $data - */ - protected function error(string $message, array $arguments = [], array $data = []) + protected function error(string $message, array $arguments = [], array $data = []): void { /** @var Logger $logger */ $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); @@ -74,32 +44,21 @@ protected function error(string $message, array $arguments = [], array $data = [ ); } - /** - * @return File - */ public function getEmergencyPlaceholderFile(): File { /** @var CloudinaryUploadService $cloudinaryUploadService */ $cloudinaryUploadService = GeneralUtility::makeInstance(CloudinaryUploadService::class); - return $cloudinaryUploadService->uploadLocalFile(''); + return $cloudinaryUploadService->getEmergencyFile(); } - /** - * @return object|CloudinaryPathService - */ - protected function getCloudinaryPathService(ResourceStorage $storage) + protected function getCloudinaryPathService(ResourceStorage $storage): CloudinaryPathService { return GeneralUtility::makeInstance( CloudinaryPathService::class, - $storage->getConfiguration() + $storage ); } - /** - * @param File $file - * - * @return string - */ public function getPublicIdForFile(File $file): string { @@ -113,9 +72,19 @@ public function getPublicIdForFile(File $file): string } // Compute the cloudinary public id - $publicId = $this + return $this ->getCloudinaryPathService($file->getStorage()) ->computeCloudinaryPublicId($file->getIdentifier()); - return $publicId; } + + public function getResourceTypeForFile(File $file): string + { + return $this->getCloudinaryPathService($file->getStorage())->getResourceType($file->getIdentifier()); + } + + protected function getUploadApi(ResourceStorage $storage): UploadApi + { + return CloudinaryApiUtility::getCloudinary($storage)->uploadApi(); + } + } diff --git a/Classes/Services/CloudinaryFolderService.php b/Classes/Services/CloudinaryFolderService.php index 1102c01..1bd1954 100644 --- a/Classes/Services/CloudinaryFolderService.php +++ b/Classes/Services/CloudinaryFolderService.php @@ -14,37 +14,18 @@ use TYPO3\CMS\Core\Database\Query\QueryBuilder; use TYPO3\CMS\Core\Utility\GeneralUtility; -/** - * Class CloudinaryFolderService - */ class CloudinaryFolderService { - /** - * @var string - */ - protected $tableName = 'tx_cloudinary_folder'; + protected string $tableName = 'tx_cloudinary_folder'; - /** - * @var int - */ - protected $storageUid; + protected int $storageUid; - /** - * CloudinaryResourceService constructor. - * - * @param int $storageUid - */ public function __construct(int $storageUid) { $this->storageUid = $storageUid; } - /** - * @param string $folder - * - * @return array - */ public function getFolder(string $folder): array { $query = $this->getQueryBuilder(); @@ -59,15 +40,10 @@ public function getFolder(string $folder): array ) ); - $folder = $query->execute()->fetch(); - return $folder - ? $folder - : []; + $folder = $query->execute()->fetchAssociative(); + return $folder ?: []; } - /** - * @return int - */ public function markAsMissing(): int { $values = ['missing' => 1,]; @@ -75,12 +51,6 @@ public function markAsMissing(): int return $this->getConnection()->update($this->tableName, $values, $identifier); } - /** - * @param string $parentFolder - * @param array $orderings - * - * @return array - */ public function getSubFolders(string $parentFolder, array $orderings, bool $recursive = false): array { $query = $this->getQueryBuilder(); @@ -104,15 +74,9 @@ public function getSubFolders(string $parentFolder, array $orderings, bool $recu ); $query->andWhere($expresion); - return $query->execute()->fetchAll(); + return $query->execute()->fetchAllAssociative(); } - /** - * @param string $parentFolder - * @param bool $recursive - * - * @return int - */ public function countSubFolders(string $parentFolder, bool $recursive = false): int { $query = $this->getQueryBuilder(); @@ -135,14 +99,9 @@ public function countSubFolders(string $parentFolder, bool $recursive = false): ); $query->andWhere($expresion); - return (int)$query->execute()->fetchColumn(0); + return (int)$query->execute()->fetchOne(0); } - /** - * @param string $folder - * - * @return int - */ public function delete(string $folder): int { $identifier['folder'] = $folder; @@ -150,22 +109,12 @@ public function delete(string $folder): int return $this->getConnection()->delete($this->tableName, $identifier); } - /** - * @param array $identifier - * - * @return int - */ - public function deleteAll(array $identifier = []): int + public function deleteAll(array $identifiers = []): int { - $identifier['storage'] = $this->storageUid; - return $this->getConnection()->delete($this->tableName, $identifier); + $identifiers['storage'] = $this->storageUid; + return $this->getConnection()->delete($this->tableName, $identifiers); } - /** - * @param string $folder - * - * @return array - */ public function save(string $folder): array { $folderHash = sha1($folder); @@ -175,11 +124,6 @@ public function save(string $folder): array : ['folder_created' => $this->add($folder)]; } - /** - * @param string $folder - * - * @return int - */ protected function add(string $folder): int { return $this->getConnection()->insert( @@ -188,12 +132,6 @@ protected function add(string $folder): int ); } - /** - * @param string $folder - * @param string $folderHash - * - * @return int - */ protected function update(string $folder, string $folderHash): int { return $this->getConnection()->update( @@ -206,11 +144,6 @@ protected function update(string $folder, string $folderHash): int ); } - /** - * @param string $folderPath - * - * @return string - */ protected function computeParentFolder(string $folderPath): string { return dirname($folderPath) === '.' @@ -218,11 +151,6 @@ protected function computeParentFolder(string $folderPath): string : dirname($folderPath); } - /** - * @param string $folderHash - * - * @return int - */ protected function exists(string $folderHash): int { $query = $this->getQueryBuilder(); @@ -237,14 +165,9 @@ protected function exists(string $folderHash): int ) ); - return (int)$query->execute()->fetchColumn(0); + return (int)$query->execute()->fetchOne(0); } - /** - * @param string $folder - * - * @return array - */ protected function getValues(string $folder): array { return [ @@ -258,12 +181,6 @@ protected function getValues(string $folder): array ]; } - /** - * @param string $key - * @param array $cloudinaryResource - * - * @return string - */ protected function getValue(string $key, array $cloudinaryResource): string { return isset($cloudinaryResource[$key]) @@ -271,9 +188,6 @@ protected function getValue(string $key, array $cloudinaryResource): string : ''; } - /** - * @return object|QueryBuilder - */ protected function getQueryBuilder(): QueryBuilder { /** @var ConnectionPool $connectionPool */ @@ -281,13 +195,11 @@ protected function getQueryBuilder(): QueryBuilder return $connectionPool->getQueryBuilderForTable($this->tableName); } - /** - * @return object|Connection - */ protected function getConnection(): Connection { /** @var ConnectionPool $connectionPool */ $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); return $connectionPool->getConnectionForTable($this->tableName); } + } diff --git a/Classes/Services/CloudinaryImageService.php b/Classes/Services/CloudinaryImageService.php index e6b41cb..fe1f962 100644 --- a/Classes/Services/CloudinaryImageService.php +++ b/Classes/Services/CloudinaryImageService.php @@ -9,67 +9,62 @@ namespace Visol\Cloudinary\Services; +use Cloudinary\Asset\Image; +use Cloudinary\Transformation\ImageTransformation; +use Cloudinary\Transformation\Scale; +use Exception; use TYPO3\CMS\Core\Resource\StorageRepository; -use Cloudinary\Uploader; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; -use TYPO3\CMS\Core\Log\LogLevel; -use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Resource\File; -use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; use Visol\Cloudinary\Domain\Repository\ExplicitDataCacheRepository; -use Visol\Cloudinary\Driver\CloudinaryDriver; use Visol\Cloudinary\Utility\CloudinaryApiUtility; -/** - * Class CloudinaryImageService - */ class CloudinaryImageService extends AbstractCloudinaryMediaService { - /** - * @var ExplicitDataCacheRepository - */ - protected $explicitDataCacheRepository; + // See "Max image megapixels" on https://cloudinary.com/pricing/compare-plans + const TRANSFORMATION_MAX_INPUT_PIXELS = 50_000_000; - /** - * @var StorageRepository - */ - protected $storageRepository; + protected ExplicitDataCacheRepository $explicitDataCacheRepository; + + protected ?StorageRepository $storageRepository = null; protected array $defaultOptions = [ 'type' => 'upload', - 'resource_type' => 'image', 'fetch_format' => 'auto', 'quality' => 'auto', ]; - /** - * - */ public function __construct() { $this->explicitDataCacheRepository = GeneralUtility::makeInstance(ExplicitDataCacheRepository::class); } - - /** - * @param File $file - * @param array $options - * - * @return array - */ public function getExplicitData(File $file, array $options): array { $publicId = $this->getPublicIdForFile($file); + $options['resource_type'] = $this->getResourceTypeForFile($file); $explicitData = $this->explicitDataCacheRepository->findByStorageAndPublicIdAndOptions($file->getStorage()->getUid(), $publicId, $options)['explicit_data']; if (!$explicitData) { - $this->initializeApi($file->getStorage()); - $explicitData = Uploader::explicit($publicId, $options); + + // With Cloudinary API 2, we need to modify the way in which "responsive_breakpoints.transformation" are transmitted. + $apiOptions = $options; + if (isset($apiOptions['responsive_breakpoints']['transformation'])) { + // Check if we need to scale the image down, before applying image transformations + $prescaleTransformation = $this->getPrescaleTransformation($file); + $transformation = new ImageTransformation($prescaleTransformation); + foreach($apiOptions['responsive_breakpoints']['transformation'] as $parameters) { + $transformation->addActionFromQualifiers($parameters); + } + $apiOptions['responsive_breakpoints']['transformation'] = $transformation; + } + try { + $explicitData = (array)$this->getUploadApi($file->getStorage())->explicit($publicId, $apiOptions); $this->explicitDataCacheRepository->save($file->getStorage()->getUid(), $publicId, $options, $explicitData); - } catch (UniqueConstraintViolationException $e) { + } catch (Exception $e) { + $explicitData = []; // ignore } } @@ -77,34 +72,18 @@ public function getExplicitData(File $file, array $options): array return $explicitData; } - /** - * @param File $file - * @param array $options - * - * @return array - */ public function getResponsiveBreakpointData(File $file, array $options): array { $explicitData = $this->getExplicitData($file, $options); - return $explicitData['responsive_breakpoints'][0]['breakpoints']; + return $explicitData['responsive_breakpoints'][0]['breakpoints'] ?? []; } - /** - * @param array $breakpoints - * - * @return string - */ public function getSrcsetAttribute(array $breakpoints): string { return implode(',' . PHP_EOL, $this->getSrcset($breakpoints)); } - /** - * @param array $breakpoints - * - * @return array - */ public function getSrcset(array $breakpoints): array { $imageObjects = $this->getImageObjects($breakpoints); @@ -116,11 +95,6 @@ public function getSrcset(array $breakpoints): array return $srcset; } - /** - * @param array $breakpoints - * - * @return string - */ public function getSizesAttribute(array $breakpoints): string { $maxImageObject = $this->getImage($breakpoints, 'max'); @@ -128,9 +102,6 @@ public function getSizesAttribute(array $breakpoints): string } /** - * @param array $breakpoints - * @param string $functionName - * * @return mixed */ public function getImage(array $breakpoints, string $functionName) @@ -166,15 +137,12 @@ public function getImageUrl(File $file, array $options = []): string $publicId = $this->getPublicIdForFile($file); - $this->initializeApi($file->getStorage()); - return \Cloudinary::cloudinary_url($publicId, $options); + $configuration = CloudinaryApiUtility::getConfiguration($file->getStorage()); + return (string)Image::fromParams($publicId, $options) + ->configuration($configuration) + ->toUrl(); } - /** - * @param array $breakpoints - * - * @return array - */ public function getImageObjects(array $breakpoints): array { $widthMap = []; @@ -185,12 +153,6 @@ public function getImageObjects(array $breakpoints): array return $widthMap; } - /** - * @param array $settings - * @param bool $enableResponsiveBreakpoints - * - * @return array - */ public function generateOptionsFromSettings(array $settings, bool $enableResponsiveBreakpoints = true): array { $transformations = []; @@ -204,10 +166,10 @@ public function generateOptionsFromSettings(array $settings, bool $enableRespons ) { $transformations[] = [ 'crop' => 'crop', - 'width' => $settings['width'], - 'height' => $settings['height'], - 'x' => $settings['x'], - 'y' => $settings['y'], + 'width' => (int)$settings['width'], + 'height' => (int)$settings['height'], + 'x' => (int)$settings['x'], + 'y' => (int)$settings['y'], ]; } @@ -265,4 +227,23 @@ public function injectStorageRepository(StorageRepository $storageRepository): v { $this->storageRepository = $storageRepository; } + + /** + * Check if cloudinary needs to scale down the image before applying + * transformations. This function will return the required scaling + * transformation or null if no scaling is required. + */ + protected function getPrescaleTransformation(File $file): ?Scale + { + $width = $file->getProperty('width') ?? 0; + $height = $file->getProperty('height') ?? 0; + + if ($width * $height <= self::TRANSFORMATION_MAX_INPUT_PIXELS) { + return null; + } + + // Calculate a width that allows the image to be processed + $maxWidth = (int)floor(sqrt(self::TRANSFORMATION_MAX_INPUT_PIXELS / ($height / $width))); + return Scale::limitFit($maxWidth); + } } diff --git a/Classes/Services/CloudinaryPathService.php b/Classes/Services/CloudinaryPathService.php index a61c42d..ba73df3 100644 --- a/Classes/Services/CloudinaryPathService.php +++ b/Classes/Services/CloudinaryPathService.php @@ -9,55 +9,48 @@ * LICENSE.md file that was distributed with this source code. */ +use RuntimeException; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\PathUtility; +use Visol\Cloudinary\Driver\CloudinaryDriver; +use Visol\Cloudinary\Utility\MimeTypeUtility; -/** - * Class CloudinaryPathService - */ class CloudinaryPathService { + protected ?ResourceStorage $storage; - /** - * @var array - */ - protected $storageConfiguration; + protected array $storageConfiguration; - /** - * CloudinaryPathService constructor. - * - * @param array $storageConfiguration - */ - public function __construct(array $storageConfiguration) + protected array $cachedCloudinaryResources = []; + + public function __construct(array|ResourceStorage $storageObjectOrConfiguration) { - $this->storageConfiguration = $storageConfiguration; + if ($storageObjectOrConfiguration instanceof ResourceStorage) { + $this->storage = $storageObjectOrConfiguration; + $this->storageConfiguration = $this->storage->getConfiguration(); + } else { + $this->storageConfiguration = $storageObjectOrConfiguration; + } } - /** - * Cloudinary to FAL identifier - * - * @param array $cloudinaryResource - * - * @return string - */ public function computeFileIdentifier(array $cloudinaryResource): string { - $fileParts = PathUtility::pathinfo($cloudinaryResource['public_id']); + $fileIdentifier = $cloudinaryResource['resource_type'] === 'raw' + ? $cloudinaryResource['public_id'] + : $cloudinaryResource['public_id'] . '.' . $cloudinaryResource['format']; - $extension = isset($fileParts['extension']) - ? '' // We don't need the extension since it is already included in the public_id (resource_type => "raw") - : '.' . $cloudinaryResource['format']; - - return self::stripBasePathFromIdentifier( - DIRECTORY_SEPARATOR . $cloudinaryResource['public_id'] . $extension, + $fileIdentifier = self::stripBasePathFromIdentifier( + DIRECTORY_SEPARATOR . $fileIdentifier, $this->getBasePath() ); + + // ensure leading slash + $fileIdentifier = '/' . ltrim($fileIdentifier, './'); + + return $fileIdentifier; } - /** - * @param string $cloudinaryFolder - * - * @return string - */ public function computeFolderIdentifier(string $cloudinaryFolder): string { return self::stripBasePathFromIdentifier( @@ -69,7 +62,6 @@ public function computeFolderIdentifier(string $cloudinaryFolder): string /** * Return the basePath. * The basePath never has a trailing slash - * @return string */ protected function getBasePath(): string { @@ -79,153 +71,91 @@ protected function getBasePath(): string : ''; } - /** - * FAL to Cloudinary identifier - * - * @param string $fileIdentifier - * - * @return string - */ public function computeCloudinaryPublicId(string $fileIdentifier): string { - $normalizedFileIdentifier = $this->guessIsImage($fileIdentifier) || $this->guessIsVideo($fileIdentifier) - ? $this->stripExtension($fileIdentifier) - : $fileIdentifier; + $fileExtension = $this->getFileExtension($fileIdentifier); + $publicId = in_array($fileExtension, CloudinaryDriver::$knownRawFormats) + ? $fileIdentifier + : $this->stripFileExtension($fileIdentifier); - return $this->normalizeCloudinaryPath($normalizedFileIdentifier); + return $this->normalizeCloudinaryPublicId($publicId); } - /** - * FAL to Cloudinary identifier - * - * @param string $folderIdentifier - * - * @return string - */ public function computeCloudinaryFolderPath(string $folderIdentifier): string { - return $this->normalizeCloudinaryPath($folderIdentifier); + return $this->normalizeCloudinaryPublicId($folderIdentifier); } - /** - * @param string $cloudinaryPath - * - * @return string - */ - public function normalizeCloudinaryPath(string $cloudinaryPath): string + public function normalizeCloudinaryPublicId(string $cloudinaryPublicId): string { - $normalizedCloudinaryPath = trim($cloudinaryPath, DIRECTORY_SEPARATOR); + $normalizedCloudinaryPath = trim($cloudinaryPublicId, DIRECTORY_SEPARATOR); $basePath = $this->getBasePath(); return $basePath ? trim($basePath . DIRECTORY_SEPARATOR . $normalizedCloudinaryPath, DIRECTORY_SEPARATOR) : $normalizedCloudinaryPath; } - /** - * @param array $fileInfo - * - * @return string - */ public function getMimeType(array $fileInfo): string { - return isset($fileInfo['mime_type']) - ? $fileInfo['mime_type'] - : ''; + return $fileInfo['mime_type'] ?? ''; } - /** - * @param string $fileIdentifier - * - * @return string - */ public function getResourceType(string $fileIdentifier): string { - $resourceType = 'raw'; - if ($this->guessIsImage($fileIdentifier)) { - $resourceType = 'image'; - } elseif ($this->guessIsVideo($fileIdentifier)) { - $resourceType = 'video'; + try { + // Find the resource type from the cloudinary resource. + $cloudinaryResource = $this->getCloudinaryResource($fileIdentifier); + } catch (RuntimeException $e) { + $fileExtension = $this->getFileExtension($fileIdentifier); + $mimeType = MimeTypeUtility::guessMimeType($fileExtension); + + // Get the primary resource type from the mime type such as image, video, audio, raw + $type = explode('/', $mimeType)[0]; + + // Equivalence table. + if ($type === 'application') { + $type = 'image'; + } elseif ($type === 'text') { + $type = 'raw'; + } + return $type; } - - return $resourceType; + return $cloudinaryResource['resource_type'] ?? 'unknown'; } - /** - * @param array $cloudinaryResource - * - * @return string - */ - public function guessMimeType(array $cloudinaryResource): string + protected function getCloudinaryResource(string $fileIdentifier): array { - $mimeType = ''; - if ($cloudinaryResource['format'] === 'pdf') { - $mimeType = 'application/pdf'; - } elseif ($cloudinaryResource['format'] === 'jpg') { - $mimeType = 'image/jpeg'; - } elseif ($cloudinaryResource['format'] === 'png') { - $mimeType = 'image/png'; - } elseif ($cloudinaryResource['format'] === 'mp4') { - $mimeType = 'video/mp4'; - } - return $mimeType; - } + $possiblePublicId = $this->stripFileExtension($fileIdentifier); - /** - * @param string $fileIdentifier - * - * @return bool - */ - protected function guessIsVideo(string $fileIdentifier) - { - $extension = strtolower(PathUtility::pathinfo($fileIdentifier, PATHINFO_EXTENSION)); - $rawExtensions = [ - 'mp4', - 'mov', + // We cache the resource for performance reasons. + if (!isset($this->cachedCloudinaryResources[$possiblePublicId])) { - 'mp3', // As documented @see https://cloudinary.com/documentation/image_upload_api_reference - ]; + // We need to check whether the public id really exists. + $cloudinaryResourceService = GeneralUtility::makeInstance( + CloudinaryResourceService::class, + $this->storage + ); - return in_array($extension, $rawExtensions, true); - } + $cloudinaryResource = $cloudinaryResourceService->getResource($possiblePublicId); - /** - * See if that is OK like that. The alternatives requires to "heavy" processing - * like downloading the file to check the mime time or use the API SDK to fetch whether - * we are in presence of an image. - * - * @param string $fileIdentifier - * - * @return bool - */ - protected function guessIsImage(string $fileIdentifier) - { - $extension = strtolower(PathUtility::pathinfo($fileIdentifier, PATHINFO_EXTENSION)); - $imageExtensions = [ - 'png', - 'jpe', - 'jpeg', - 'jpg', - 'gif', - 'bmp', - 'ico', - 'tiff', - 'tif', - 'svg', - 'svgz', - 'webp', - - 'pdf', // Cloudinary handles pdf as image - ]; - - return in_array($extension, $imageExtensions, true); + // Try to retrieve the cloudinary with the file identifier. + // That will be the case for raw resources. + if (!$cloudinaryResource) { + $cloudinaryResource = $cloudinaryResourceService->getResource($fileIdentifier); + } + + // Houston, we have a problem. The public id does not exist, meaning the file does not exist. + if (!$cloudinaryResource) { + throw new RuntimeException('Cloudinary resource not found for ' . $fileIdentifier, 1623157880); + } + + $this->cachedCloudinaryResources[$possiblePublicId] = $cloudinaryResource; + } + + return $this->cachedCloudinaryResources[$possiblePublicId]; } - /** - * @param $filename - * - * @return string - */ - protected function stripExtension(string $filename): string + protected function stripFileExtension(string $filename): string { $pathParts = PathUtility::pathinfo($filename); @@ -236,6 +166,12 @@ protected function stripExtension(string $filename): string return $pathParts['dirname'] . DIRECTORY_SEPARATOR . $pathParts['filename']; } + protected function getFileExtension(string $filename): string + { + $pathInfo = PathUtility::pathinfo($filename); + return $pathInfo['extension'] ?? ''; + } + public static function stripBasePathFromIdentifier(string $identifierWithBasePath, string $basePath): string { return preg_replace( diff --git a/Classes/Services/CloudinaryResourceService.php b/Classes/Services/CloudinaryResourceService.php index 8cfafa9..a223b8b 100644 --- a/Classes/Services/CloudinaryResourceService.php +++ b/Classes/Services/CloudinaryResourceService.php @@ -20,29 +20,15 @@ */ class CloudinaryResourceService { - /** - * @var string - */ - protected $tableName = 'tx_cloudinary_resource'; - - /** - * @var ResourceStorage - */ - protected $storage; - - /** - * CloudinaryResourceService constructor. - * - * @param ResourceStorage $storage - */ + protected string $tableName = 'tx_cloudinary_cache_resources'; + + protected ResourceStorage $storage; + public function __construct(ResourceStorage $storage) { $this->storage = $storage; } - /** - * @return int - */ public function markAsMissing(): int { $values = ['missing' => 1]; @@ -50,11 +36,6 @@ public function markAsMissing(): int return $this->getConnection()->update($this->tableName, $values, $identifier); } - /** - * @param string $publicId - * - * @return array - */ public function getResource(string $publicId): array { $query = $this->getQueryBuilder(); @@ -68,23 +49,16 @@ public function getResource(string $publicId): array ->setMaxResults(1); $resource = $query->execute()->fetchAssociative(); - return $resource ? $resource : []; + return $resource ?: []; } - /** - * @param string $folder - * @param array $orderings - * @param array $pagination - * @param bool $recursive - * - * @return array - */ public function getResources( string $folder, - array $orderings = [], - array $pagination = [], - bool $recursive = false - ): array { + array $orderings = [], + array $pagination = [], + bool $recursive = false + ): array + { $query = $this->getQueryBuilder(); $query ->select('*') @@ -92,28 +66,22 @@ public function getResources( ->where($query->expr()->eq('storage', $this->storage->getUid())); // We should handle recursion - $expresion = $recursive + $expression = $recursive ? $query->expr()->like('folder', $query->expr()->literal($folder . '%')) : $query->expr()->eq('folder', $query->expr()->literal($folder)); - $query->andWhere($expresion); + $query->andWhere($expression); if ($orderings) { $query->orderBy($orderings['fieldName'], $orderings['direction']); } - if ($pagination && (int) $pagination['maxResult'] > 0) { - $query->setMaxResults((int) $pagination['maxResult']); - $query->setFirstResult((int) $pagination['firstResult']); + if ($pagination && (int)$pagination['maxResult'] > 0) { + $query->setMaxResults((int)$pagination['maxResult']); + $query->setFirstResult((int)$pagination['firstResult']); } - return $query->execute()->fetchAll(); + return $query->execute()->fetchAllAssociative(); } - /** - * @param string $folder - * @param bool $recursive - * - * @return int - */ public function count(string $folder, bool $recursive = false): int { $query = $this->getQueryBuilder(); @@ -128,14 +96,9 @@ public function count(string $folder, bool $recursive = false): int : $query->expr()->eq('folder', $query->expr()->literal($folder)); $query->andWhere($expresion); - return (int) $query->execute()->fetchColumn(0); + return (int)$query->execute()->fetchOne(0); } - /** - * @param string $publicId - * - * @return int - */ public function delete(string $publicId): int { $identifier['public_id'] = $publicId; @@ -143,22 +106,12 @@ public function delete(string $publicId): int return $this->getConnection()->delete($this->tableName, $identifier); } - /** - * @param array $identifier - * - * @return int - */ - public function deleteAll(array $identifier = []): int + public function deleteAll(array $identifiers = []): int { - $identifier['storage'] = $this->storage->getUid(); - return $this->getConnection()->delete($this->tableName, $identifier); + $identifiers['storage'] = $this->storage->getUid(); + return $this->getConnection()->delete($this->tableName, $identifiers); } - /** - * @param array $cloudinaryResource - * - * @return array - */ public function save(array $cloudinaryResource): array { $publicIdHash = $this->getPublicIdHash($cloudinaryResource); @@ -169,27 +122,24 @@ public function save(array $cloudinaryResource): array $this->getCloudinaryFolderService()->save($folder); } - return $this->exists($publicIdHash) - ? ['updated' => $this->update($cloudinaryResource, $publicIdHash), 'publicIdHash' => $publicIdHash] - : ['created' => $this->add($cloudinaryResource), 'publicIdHash' => $publicIdHash]; + $result = $this->exists($publicIdHash) + ? ['updated' => $this->update($cloudinaryResource, $publicIdHash),] + : ['created' => $this->add($cloudinaryResource),]; + + return array_merge( + $result, + [ + 'publicIdHash' => $publicIdHash, + 'resource' => $cloudinaryResource, + ] + ); } - /** - * @param array $cloudinaryResource - * - * @return int - */ protected function add(array $cloudinaryResource): int { return $this->getConnection()->insert($this->tableName, $this->getValues($cloudinaryResource)); } - /** - * @param array $cloudinaryResource - * @param string $publicIdHash - * - * @return int - */ protected function update(array $cloudinaryResource, string $publicIdHash): int { return $this->getConnection()->update($this->tableName, $this->getValues($cloudinaryResource), [ @@ -198,11 +148,6 @@ protected function update(array $cloudinaryResource, string $publicIdHash): int ]); } - /** - * @param string $publicIdHash - * - * @return int - */ protected function exists(string $publicIdHash): int { $query = $this->getQueryBuilder(); @@ -214,14 +159,9 @@ protected function exists(string $publicIdHash): int $query->expr()->eq('public_id_hash', $query->expr()->literal($publicIdHash)), ); - return (int) $query->execute()->fetchOne(0); + return (int)$query->execute()->fetchOne(0); } - /** - * @param array $cloudinaryResource - * - * @return array - */ protected function getValues(array $cloudinaryResource): array { $publicIdHash = $this->getPublicIdHash($cloudinaryResource); @@ -232,16 +172,16 @@ protected function getValues(array $cloudinaryResource): array 'folder' => $this->getFolder($cloudinaryResource), 'filename' => $this->getFileName($cloudinaryResource), 'format' => $this->getValue('format', $cloudinaryResource), - 'version' => (int) $this->getValue('version', $cloudinaryResource), + 'version' => (int)$this->getValue('version', $cloudinaryResource), 'resource_type' => $this->getValue('resource_type', $cloudinaryResource), 'type' => $this->getValue('type', $cloudinaryResource), 'created_at' => $this->getCreatedAt($cloudinaryResource), 'uploaded_at' => $this->getUpdatedAt($cloudinaryResource), - 'bytes' => (int) $this->getValue('bytes', $cloudinaryResource), - 'width' => (int) $this->getValue('width', $cloudinaryResource), - 'height' => (int) $this->getValue('height', $cloudinaryResource), - 'aspect_ratio' => (float) $this->getValue('aspect_ratio', $cloudinaryResource), - 'pixels' => (int) $this->getValue('pixels', $cloudinaryResource), + 'bytes' => (int)$this->getValue('bytes', $cloudinaryResource), + 'width' => (int)$this->getValue('width', $cloudinaryResource), + 'height' => (int)$this->getValue('height', $cloudinaryResource), + 'aspect_ratio' => (float)$this->getValue('aspect_ratio', $cloudinaryResource), + 'pixels' => (int)$this->getValue('pixels', $cloudinaryResource), 'url' => $this->getValue('url', $cloudinaryResource), 'secure_url' => $this->getValue('secure_url', $cloudinaryResource), 'status' => $this->getValue('status', $cloudinaryResource), @@ -255,85 +195,47 @@ protected function getValues(array $cloudinaryResource): array ]; } - /** - * @param string $key - * @param array $cloudinaryResource - * - * @return string - */ protected function getValue(string $key, array $cloudinaryResource): string { - return isset($cloudinaryResource[$key]) ? (string) $cloudinaryResource[$key] : ''; + return isset($cloudinaryResource[$key]) ? (string)$cloudinaryResource[$key] : ''; } - /** - * @param array $cloudinaryResource - * - * @return string - */ protected function getFileName(array $cloudinaryResource): string { return basename($this->getValue('public_id', $cloudinaryResource)); } - /** - * @param array $cloudinaryResource - * - * @return string - */ protected function getFolder(array $cloudinaryResource): string { $folder = dirname($this->getValue('public_id', $cloudinaryResource)); return $folder === '.' ? '' : $folder; } - /** - * @param array $cloudinaryResource - * - * @return string - */ protected function getCreatedAt(array $cloudinaryResource): string { $createdAt = $this->getValue('created_at', $cloudinaryResource); return date('Y-m-d h:i:s', strtotime($createdAt)); } - /** - * @param array $cloudinaryResource - * - * @return string - */ protected function getPublicIdHash(array $cloudinaryResource): string { $publicId = $this->getValue('public_id', $cloudinaryResource); return sha1($publicId); } - /** - * @param array $cloudinaryResource - * - * @return string - */ protected function getUpdatedAt(array $cloudinaryResource): string { $updatedAt = $this->getValue('updated_at', $cloudinaryResource) - ? $this->getValue('updated_at', $cloudinaryResource) - : $this->getValue('created_at', $cloudinaryResource); + ?: $this->getValue('created_at', $cloudinaryResource); return date('Y-m-d h:i:s', strtotime($updatedAt)); } - /** - * @return object|CloudinaryFolderService - */ protected function getCloudinaryFolderService(): CloudinaryFolderService { return GeneralUtility::makeInstance(CloudinaryFolderService::class, $this->storage->getUid()); } - /** - * @return object|QueryBuilder - */ protected function getQueryBuilder(): QueryBuilder { /** @var ConnectionPool $connectionPool */ @@ -341,9 +243,6 @@ protected function getQueryBuilder(): QueryBuilder return $connectionPool->getQueryBuilderForTable($this->tableName); } - /** - * @return object|Connection - */ protected function getConnection(): Connection { /** @var ConnectionPool $connectionPool */ diff --git a/Classes/Services/CloudinaryScanService.php b/Classes/Services/CloudinaryScanService.php index 2d5eff7..cd9dbc5 100644 --- a/Classes/Services/CloudinaryScanService.php +++ b/Classes/Services/CloudinaryScanService.php @@ -8,8 +8,11 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ + +use Cloudinary\Api\Admin\AdminApi; +use Cloudinary\Api\Search\SearchApi; +use TYPO3\CMS\Core\Exception; use TYPO3\CMS\Core\Log\Logger; -use Cloudinary\Search; use Symfony\Component\Console\Style\SymfonyStyle; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\QueryBuilder; @@ -20,9 +23,6 @@ use Visol\Cloudinary\Driver\CloudinaryDriver; use Visol\Cloudinary\Utility\CloudinaryApiUtility; -/** - * Class CloudinaryScanService - */ class CloudinaryScanService { @@ -33,25 +33,15 @@ class CloudinaryScanService private const FAILED = 'failed'; private const FOLDER_DELETED = 'folder_deleted'; - /** - * @var ResourceStorage - */ - protected $storage; - - /** - * @var CloudinaryPathService - */ - protected $cloudinaryPathService; - - /** - * @var string - */ - protected $processedFolder = '_processed_'; - - /** - * @var array - */ - protected $statistics = [ + protected ResourceStorage $storage; + + protected ?CloudinaryPathService $cloudinaryPathService = null; + + protected string $processedFolder = '_processed_'; + + protected string $additionalExpression = ''; + + protected array $statistics = [ self::CREATED => 0, self::UPDATED => 0, self::DELETED => 0, @@ -61,19 +51,8 @@ class CloudinaryScanService self::FOLDER_DELETED => 0, ]; - /** - * @var SymfonyStyle|null - */ - protected $io; - - /** - * CloudinaryScanService constructor. - * - * @param ResourceStorage $storage - * @param SymfonyStyle|null $io - * - * @throws \Exception - */ + protected ?SymfonyStyle $io = null; + public function __construct(ResourceStorage $storage, SymfonyStyle $io = null) { if ($storage->getDriverType() !== CloudinaryDriver::DRIVER_TYPE) { @@ -83,47 +62,46 @@ public function __construct(ResourceStorage $storage, SymfonyStyle $io = null) $this->io = $io; } - /** - * @return void - */ - public function empty(): void + public function scanOne(string $publicId): array|null { - $this->getCloudinaryResourceService()->deleteAll(); - $this->getCloudinaryFolderService()->deleteAll(); + try { + $resource = (array)$this->getAdminApi()->asset($publicId); + $result = $this->getCloudinaryResourceService()->save($resource); + } catch (Exception $exception) { + $result = null; + } + return $result; } - /** - * @return array - */ public function scan(): array { $this->preScan(); - // Before calling the Search API, make sure we are connected with the right cloudinary account - $this->initializeApi(); - $cloudinaryFolder = $this->getCloudinaryPathService()->computeCloudinaryFolderPath(DIRECTORY_SEPARATOR); + // We initialize the array. + $expressions = []; + // Add a filter if the root directory contains a base path segment // + remove _processed_ folder from the search if ($cloudinaryFolder) { $expressions[] = sprintf('folder=%s/*', $cloudinaryFolder); $expressions[] = sprintf('NOT folder=%s/%s/*', $cloudinaryFolder, $this->processedFolder); - } else { - $expressions[] = sprintf('NOT folder=%s/*', $this->processedFolder); } - if ($this->io) { - $this->io->writeln('Mirroring...' . chr(10)); + if ($this->additionalExpression) { + $expressions[] = $this->additionalExpression; } + $this->console('Mirroring...', true); + do { $nextCursor = isset($response) ? $response['next_cursor'] : ''; - $this->log( - '[API][SEARCH] Cloudinary\Search() - fetch resources from folder "%s" %s', + $this->info( + '[API] SearchApi - fetch resources from folder "%s" %s', [ $cloudinaryFolder, $nextCursor ? 'and cursor ' . $nextCursor : '', @@ -133,34 +111,42 @@ public function scan(): array ] ); - /** @var Search $search */ - $search = new Search(); - - $response = $search + $response = $this->getSearchApi() ->expression(implode(' AND ', $expressions)) - ->sort_by('public_id', 'asc') - ->max_results(500) - ->next_cursor($nextCursor) + ->sortBy('public_id', 'asc') + ->maxResults(500) + ->nextCursor($nextCursor) ->execute(); if (is_array($response['resources'])) { foreach ($response['resources'] as $resource) { $fileIdentifier = $this->getCloudinaryPathService()->computeFileIdentifier($resource); + + // Skip files in the processed folder is detected. + if (str_contains($fileIdentifier, $this->processedFolder)) { + $this->console('Skipped processed file ' . $fileIdentifier); + continue; + } elseif ($resource['resource_type'] === 'raw' + && !in_array($resource['format'], CloudinaryDriver::$knownRawFormats, true)) { + // Skip as well if the resource is of type raw + // We might have problem when indexing video such as .youtube and .vimeo + // which are not well-supported between cloudinary and typo3 + $this->console('Skipped unknown raw file ' . $fileIdentifier); + continue; + } + try { - if ($this->io) { - $this->io->writeln($fileIdentifier); - } // Save mirrored file $result = $this->getCloudinaryResourceService()->save($resource); + $isCreated = isset($result['created']) ? '(new)' : ''; + $this->console('Scanned ' . $fileIdentifier . ' ' . $isCreated); + // Find if the file exists in sys_file already if (!$this->fileExistsInStorage($fileIdentifier)) { - if ($this->io) { - $this->io->writeln('Indexing new file: ' . $fileIdentifier); - $this->io->writeln(''); - } + $this->console('New file will be indexed in typo3 ' . $fileIdentifier, true); // This will trigger a file indexation $this->storage->getFile($fileIdentifier); @@ -173,12 +159,9 @@ public function scan(): array // In any case we can add a file to the counter. // Later we can verify the total corresponds to the "created" + "updated" + "deleted" files $this->statistics[self::TOTAL]++; - } - catch (\Exception $e) { + } catch (\Exception $e) { $this->statistics[self::FAILED]++; - if ($this->io) { - $this->io->warning(sprintf('Error could not process "%s"', $fileIdentifier)); - } + $this->console(sprintf('Error could not process "%s"', $fileIdentifier)); // ignore } } @@ -190,30 +173,19 @@ public function scan(): array return $this->statistics; } - /** - * @return void - */ protected function preScan(): void { $this->getCloudinaryResourceService()->markAsMissing(); $this->getCloudinaryFolderService()->markAsMissing(); } - /** - * @return void - */ protected function postScan(): void { - $identifier = ['missing' => 1]; - $this->statistics[self::DELETED] = $this->getCloudinaryResourceService()->deleteAll($identifier); - $this->statistics[self::FOLDER_DELETED] = $this->getCloudinaryFolderService()->deleteAll($identifier); + $identifiers = ['missing' => 1]; + $this->statistics[self::DELETED] = $this->getCloudinaryResourceService()->deleteAll($identifiers); + $this->statistics[self::FOLDER_DELETED] = $this->getCloudinaryFolderService()->deleteAll($identifiers); } - /** - * @param string $fileIdentifier - * - * @return bool - */ protected function fileExistsInStorage(string $fileIdentifier): bool { $query = $this->getQueryBuilder(); @@ -221,8 +193,8 @@ protected function fileExistsInStorage(string $fileIdentifier): bool ->from('sys_file') ->where( $this->getQueryBuilder()->expr()->eq( - 'identifier', - $query->expr()->literal($fileIdentifier) + 'identifier_hash', + $query->expr()->literal($this->storage->hashFileIdentifier($fileIdentifier)), ), $this->getQueryBuilder()->expr()->eq( 'storage', @@ -230,12 +202,9 @@ protected function fileExistsInStorage(string $fileIdentifier): bool ) ); - return (bool)$query->execute()->fetchColumn(0); + return (bool)$query->execute()->fetchOne(0); } - /** - * @return object|QueryBuilder - */ protected function getQueryBuilder(): QueryBuilder { /** @var ConnectionPool $connectionPool */ @@ -243,51 +212,39 @@ protected function getQueryBuilder(): QueryBuilder return $connectionPool->getQueryBuilderForTable('sys_file'); } - /** - * @return void - */ - protected function initializeApi() - { - CloudinaryApiUtility::initializeByConfiguration($this->storage->getConfiguration()); - } - - /** - * @return object|CloudinaryResourceService - */ protected function getCloudinaryResourceService(): CloudinaryResourceService { return GeneralUtility::makeInstance(CloudinaryResourceService::class, $this->storage); } - /** - * @return object|CloudinaryFolderService - */ protected function getCloudinaryFolderService(): CloudinaryFolderService { return GeneralUtility::makeInstance(CloudinaryFolderService::class, $this->storage->getUid()); } - /** - * @return CloudinaryPathService - */ protected function getCloudinaryPathService(): CloudinaryPathService { if (!$this->cloudinaryPathService) { $this->cloudinaryPathService = GeneralUtility::makeInstance( CloudinaryPathService::class, - $this->storage->getConfiguration() + $this->storage ); } return $this->cloudinaryPathService; } - /** - * @param string $message - * @param array $arguments - * @param array $data - */ - protected function log(string $message, array $arguments = [], array $data = []) + protected function getSearchApi(): SearchApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->searchApi(); + } + + protected function getAdminApi(): AdminApi + { + return CloudinaryApiUtility::getCloudinary($this->storage)->adminApi(); + } + + protected function info(string $message, array $arguments = [], array $data = []): void { /** @var Logger $logger */ $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); @@ -297,4 +254,20 @@ protected function log(string $message, array $arguments = [], array $data = []) $data ); } + + protected function console(string $message, $additionalBlankLine = false): void + { + if ($this->io) { + $this->io->writeln($message); + if ($additionalBlankLine) { + $this->io->writeln(''); + } + } + } + + public function setAdditionalExpression(string $additionalExpression): CloudinaryScanService + { + $this->additionalExpression = $additionalExpression; + return $this; + } } diff --git a/Classes/Services/CloudinaryTestConnectionService.php b/Classes/Services/CloudinaryTestConnectionService.php index ab8f068..d696274 100644 --- a/Classes/Services/CloudinaryTestConnectionService.php +++ b/Classes/Services/CloudinaryTestConnectionService.php @@ -8,12 +8,13 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ -use Cloudinary\Search; + +use Cloudinary\Api\Search\SearchApi; +use Exception; use TYPO3\CMS\Core\Messaging\FlashMessageQueue; use TYPO3\CMS\Core\Messaging\FlashMessage; use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Object\ObjectManager; use TYPO3\CMS\Extbase\Utility\LocalizationUtility; use Visol\Cloudinary\Utility\CloudinaryApiUtility; @@ -51,10 +52,7 @@ public function test() $messageQueue = $this->getMessageQueue(); $localizationPrefix = $this->languageFile . ':driverConfiguration.message.'; try { - $this->initializeApi(); - - $search = new Search(); - $search + $search = $this->getSearchApi() ->expression('folder=/') ->execute(); @@ -66,7 +64,7 @@ public function test() FlashMessage::OK ); $messageQueue->addMessage($message); - } catch (\Exception $exception) { + } catch (Exception $exception) { /** @var FlashMessage $message */ $message = GeneralUtility::makeInstance( FlashMessage::class, @@ -88,13 +86,9 @@ protected function getMessageQueue() return $flashMessageService->getMessageQueueByIdentifier(); } - /** - * @return void - */ - protected function initializeApi() + protected function getSearchApi(): SearchApi { - CloudinaryApiUtility::initializeByConfiguration($this->configuration); + return CloudinaryApiUtility::getCloudinary($this->configuration)->searchApi(); } - } diff --git a/Classes/Services/CloudinaryUploadService.php b/Classes/Services/CloudinaryUploadService.php index 96c68f0..144e2cc 100644 --- a/Classes/Services/CloudinaryUploadService.php +++ b/Classes/Services/CloudinaryUploadService.php @@ -14,40 +14,22 @@ use TYPO3\CMS\Core\Log\LogLevel; use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Resource\File; -use TYPO3\CMS\Core\Resource\FileInterface; use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; use Visol\Cloudinary\CloudinaryFactory; -/** - * Class CloudinaryUploadService - */ class CloudinaryUploadService { - /** - * @var string - */ - protected $emergencyFileIdentifier = '/typo3conf/ext/cloudinary/Resources/Public/Images/emergency-placeholder-image.png'; + protected string $emergencyFileIdentifier = '/typo3conf/ext/cloudinary/Resources/Public/Images/emergency-placeholder-image.png'; - /** - * @var ResourceStorage - */ - protected $storage; + protected ResourceStorage $storage; - /** - * @param ResourceStorage $storage - */ public function __construct(ResourceStorage $storage = null) { $this->storage = $storage ?: CloudinaryFactory::getDefaultStorage(); } - /** - * @param string $fileIdentifier - * - * @return File|FileInterface - */ - public function uploadLocalFile(string $fileIdentifier) + public function uploadLocalFile(string $fileIdentifier): File { // Cleanup file identifier in case $fileIdentifier = $this->cleanUp($fileIdentifier); @@ -68,19 +50,16 @@ public function uploadLocalFile(string $fileIdentifier) ); } - /** - * @param string $fileIdentifier - */ - protected function cleanUp(string $fileIdentifier) + public function getEmergencyFile(): File + { + return $this->uploadLocalFile($this->emergencyFileIdentifier); + } + + protected function cleanUp(string $fileIdentifier): string { return DIRECTORY_SEPARATOR . ltrim($fileIdentifier, DIRECTORY_SEPARATOR); } - /** - * @param string $fileIdentifier - * - * @return bool - */ protected function fileExists(string $fileIdentifier): bool { $fileNameAndPath = @@ -88,12 +67,7 @@ protected function fileExists(string $fileIdentifier): bool return is_file($fileNameAndPath); } - /** - * @param string $message - * @param array $arguments - * @param array $data - */ - protected function error(string $message, array $arguments = [], array $data = []) + protected function error(string $message, array $arguments = [], array $data = []): void { /** @var Logger $logger */ $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); diff --git a/Classes/Services/CloudinaryVideoService.php b/Classes/Services/CloudinaryVideoService.php index 4e6dd35..971b457 100644 --- a/Classes/Services/CloudinaryVideoService.php +++ b/Classes/Services/CloudinaryVideoService.php @@ -2,11 +2,13 @@ namespace Visol\Cloudinary\Services; +use Cloudinary\Asset\Video; use TYPO3\CMS\Core\Resource\File; +use Visol\Cloudinary\Utility\CloudinaryApiUtility; class CloudinaryVideoService extends AbstractCloudinaryMediaService { - protected $defaultOptions = [ + protected array $defaultOptions = [ 'type' => 'upload', 'resource_type' => 'video', 'fetch_format' => 'auto', @@ -19,7 +21,10 @@ public function getVideoUrl(File $file, array $options = []): string $publicId = $this->getPublicIdForFile($file); - $this->initializeApi($file->getStorage()); - return \Cloudinary::cloudinary_url($publicId, $options); + $configuration = CloudinaryApiUtility::getConfiguration($file->getStorage()); + + return Video::fromParams($publicId, $options) + ->configuration($configuration) + ->toUrl(); } } diff --git a/Classes/Services/ConfigurationService.php b/Classes/Services/ConfigurationService.php index 32fa820..51896f7 100644 --- a/Classes/Services/ConfigurationService.php +++ b/Classes/Services/ConfigurationService.php @@ -10,15 +10,11 @@ */ -/** - * Class ConfigurationService - */ +use RuntimeException; + class ConfigurationService { - /** - * @var array - */ - protected $configuration = []; + protected array $configuration = []; /** * ConfigurationService constructor. @@ -33,12 +29,12 @@ public function __construct(array $configuration) */ public function get(string $key): string { - $value = (string)$this->configuration[$key]; - if (preg_match('/^%(.*)%$/', $value, $matches)) { + $value = trim((string)$this->configuration[$key]); + if (preg_match('/^%env\((.*)\)%$/', $value, $matches) || preg_match('/^%(.*)%$/', $value, $matches)) { $value = getenv($matches[1]); if ($value === false) { - throw new \RuntimeException(sprintf('No value found for environment variable "%s"', $matches[1]), 1626948978); + throw new RuntimeException(sprintf('No value found for environment variable "%s"', $matches[1]), 1626948978); } $value = (string)$value; diff --git a/Classes/Services/Extractor/CloudinaryMetaDataExtractor.php b/Classes/Services/Extractor/CloudinaryMetaDataExtractor.php index 9b4a564..67d8136 100644 --- a/Classes/Services/Extractor/CloudinaryMetaDataExtractor.php +++ b/Classes/Services/Extractor/CloudinaryMetaDataExtractor.php @@ -2,20 +2,15 @@ namespace Visol\Cloudinary\Services\Extractor; -use Cloudinary; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerAwareTrait; -use Psr\Log\LogLevel; -use TYPO3\CMS\Core\Resource; +use Cloudinary\Api\Admin\AdminApi; use TYPO3\CMS\Core\Resource\File; use TYPO3\CMS\Core\Resource\Index\ExtractorInterface; use TYPO3\CMS\Core\Resource\ResourceStorage; -use TYPO3\CMS\Core\Type\File\ImageInfo; use TYPO3\CMS\Core\Utility\GeneralUtility; -use Visol\Cloudinary\Driver\CloudinaryFastDriver; +use Visol\Cloudinary\Driver\CloudinaryDriver; use Visol\Cloudinary\Services\CloudinaryPathService; use Visol\Cloudinary\Services\CloudinaryResourceService; -use Visol\Cloudinary\Services\ConfigurationService; +use Visol\Cloudinary\Utility\CloudinaryApiUtility; class CloudinaryMetaDataExtractor implements ExtractorInterface { @@ -38,7 +33,7 @@ public function getFileTypeRestrictions(): array */ public function getDriverRestrictions(): array { - return [CloudinaryFastDriver::DRIVER_TYPE]; + return [CloudinaryDriver::DRIVER_TYPE]; } /** @@ -71,18 +66,17 @@ public function extractMetaData(File $file, array $previousExtractedData = []): $cloudinaryPathService = GeneralUtility::makeInstance( CloudinaryPathService::class, - $file->getStorage()->getConfiguration(), + $file->getStorage(), ); $publicId = $cloudinaryPathService->computeCloudinaryPublicId($file->getIdentifier()); $resource = $cloudinaryResourceService->getResource($publicId); // We are force calling cloudinary API if (!$resource) { - // Ask cloudinary to fetch the resource for us - $this->initializeApi($file->getStorage()); - $api = new Cloudinary\Api(); - $resource = $api->resource($publicId); + // Ask cloudinary to fetch the resource for us + $resource = $this->getAdminApi($file->getStorage()) + ->asset($publicId); } return [ @@ -91,16 +85,9 @@ public function extractMetaData(File $file, array $previousExtractedData = []): ]; } - protected function initializeApi(ResourceStorage $storage): void + protected function getAdminApi(ResourceStorage $storage): AdminApi { - $configurationService = GeneralUtility::makeInstance(ConfigurationService::class, $storage->getConfiguration()); - - Cloudinary::config([ - 'cloud_name' => $configurationService->get('cloudName'), - 'api_key' => $configurationService->get('apiKey'), - 'api_secret' => $configurationService->get('apiSecret'), - 'timeout' => $configurationService->get('timeout'), - 'secure' => true, - ]); + return CloudinaryApiUtility::getCloudinary($storage)->adminApi(); } + } diff --git a/Classes/Services/FileMoveService.php b/Classes/Services/FileMoveService.php index d7cb39f..0302915 100644 --- a/Classes/Services/FileMoveService.php +++ b/Classes/Services/FileMoveService.php @@ -8,9 +8,11 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ -use Cloudinary\Api; -use Cloudinary\Uploader; + +use Cloudinary\Api\Admin\AdminApi; +use Cloudinary\Api\Upload\UploadApi; use Doctrine\DBAL\Driver\Connection; +use Exception; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\QueryBuilder; use TYPO3\CMS\Core\Resource\File; @@ -18,31 +20,15 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use Visol\Cloudinary\Utility\CloudinaryApiUtility; -/** - * Class FileMoveService - */ class FileMoveService { - /** - * @var string - */ - protected $tableName = 'sys_file'; - - /** - * @var CloudinaryPathService - */ - protected $cloudinaryPathService; - - /** - * @param File $fileObject - * @param ResourceStorage $targetStorage - * - * @return bool - */ + protected string $tableName = 'sys_file'; + + protected ?CloudinaryPathService $cloudinaryPathService = null; + public function fileExists(File $fileObject, ResourceStorage $targetStorage): bool { - $this->initializeApi($targetStorage); $this->initializeCloudinaryService($targetStorage); // Retrieve the Public Id based on the file identifier @@ -50,23 +36,15 @@ public function fileExists(File $fileObject, ResourceStorage $targetStorage): bo ->computeCloudinaryPublicId($fileObject->getIdentifier()); try { - $api = new Api(); - $resource = $api->resource($publicId); + $resource = $this->getAdminApi($targetStorage)->asset($publicId); $fileExists = !empty($resource); - } catch (\Exception $exception) { + } catch (Exception $exception) { $fileExists = false; } return $fileExists; } - /** - * @param File $fileObject - * @param ResourceStorage $targetStorage - * @param bool $removeFile - * - * @return bool - */ #public function forceMove(File $fileObject, ResourceStorage $targetStorage, $removeFile = true): bool #{ # $isUpdated = $isDeletedFromSourceStorage = false; @@ -96,14 +74,7 @@ public function fileExists(File $fileObject, ResourceStorage $targetStorage): bo # return $isUpdated && $isDeletedFromSourceStorage; #} - /** - * @param File $fileObject - * @param ResourceStorage $targetStorage - * @param bool $removeFile - * - * @return bool - */ - public function changeStorage(File $fileObject, ResourceStorage $targetStorage, $removeFile = true): bool + public function changeStorage(File $fileObject, ResourceStorage $targetStorage, bool $removeFile = true): bool { // Update the storage uid $isMigrated = (bool)$this->updateFile( @@ -121,9 +92,6 @@ public function changeStorage(File $fileObject, ResourceStorage $targetStorage, return $isMigrated; } - /** - * @param File $fileObject - */ protected function ensureDirectoryExistence(File $fileObject) { @@ -134,11 +102,6 @@ protected function ensureDirectoryExistence(File $fileObject) } } - /** - * @param File $fileObject - * - * @return string - */ protected function getAbsolutePath(File $fileObject): string { // Compute the absolute file name of the file to move @@ -147,11 +110,6 @@ protected function getAbsolutePath(File $fileObject): string return GeneralUtility::getFileAbsFileName($fileRelativePath); } - /** - * @param File $fileObject - * @param ResourceStorage $targetStorage - * @param string $baseUrl - */ public function cloudinaryUploadFile( File $fileObject, ResourceStorage $targetStorage, @@ -162,7 +120,6 @@ public function cloudinaryUploadFile( $this->ensureDirectoryExistence($fileObject); - $this->initializeApi($targetStorage); $fileIdentifier = $fileObject->getIdentifier(); $publicId = $this->getCloudinaryPathService() @@ -182,23 +139,12 @@ public function cloudinaryUploadFile( : $this->getAbsolutePath($fileObject); // Upload the file - $resource = Uploader::upload( + $this->getUploadApi($targetStorage)->upload( $fileNameAndPath, $options ); } - /** - * @param ResourceStorage $targetStorage - */ - protected function initializeApi(ResourceStorage $targetStorage) - { - CloudinaryApiUtility::initializeByConfiguration($targetStorage->getConfiguration()); - } - - /** - * @return object|QueryBuilder - */ protected function getQueryBuilder(): QueryBuilder { /** @var ConnectionPool $connectionPool */ @@ -206,9 +152,6 @@ protected function getQueryBuilder(): QueryBuilder return $connectionPool->getQueryBuilderForTable($this->tableName); } - /** - * @return object|Connection - */ protected function getConnection(): Connection { /** @var ConnectionPool $connectionPool */ @@ -216,12 +159,6 @@ protected function getConnection(): Connection return $connectionPool->getConnectionForTable($this->tableName); } - /** - * @param File $fileObject - * @param array $values - * - * @return int - */ protected function updateFile(File $fileObject, array $values): int { $connection = $this->getConnection(); @@ -234,22 +171,27 @@ protected function updateFile(File $fileObject, array $values): int ); } - /** - * @return object|CloudinaryPathService - */ - protected function getCloudinaryPathService() + protected function getCloudinaryPathService(): CloudinaryPathService { return $this->cloudinaryPathService; } - /** - * @param ResourceStorage $storage - */ protected function initializeCloudinaryService(ResourceStorage $storage) { $this->cloudinaryPathService = GeneralUtility::makeInstance( CloudinaryPathService::class, - $storage->getStorageRecord() + $storage ); } + + protected function getUploadApi(ResourceStorage $storage): UploadApi + { + return CloudinaryApiUtility::getCloudinary($storage)->uploadApi(); + } + + protected function getAdminApi(ResourceStorage $storage): AdminApi + { + return CloudinaryApiUtility::getCloudinary($storage)->adminApi(); + } + } diff --git a/Classes/Services/ProcessingTaskConverter.php b/Classes/Services/ProcessingTaskConverter.php new file mode 100644 index 0000000..dd012b8 --- /dev/null +++ b/Classes/Services/ProcessingTaskConverter.php @@ -0,0 +1,116 @@ +getConfiguration(); + + if (isset($processingConfiguration['maskImages'])) { + throw new NoCloudinaryTransformation('Unable to maskImages', 1712760855588); + } + + $transformations = []; + + $cropTransformation = $this->getCropTransformation($task); + if (isset($cropTransformation)) { + $transformations[] = $cropTransformation; + } + + $scaleTransformation = $this->getScaleTransformation($task); + if (isset($scaleTransformation)) { + $transformations[] = $scaleTransformation; + } + + return $transformations; + } + + protected function getCropTransformation(TaskInterface $task): ?Crop + { + $processingConfiguration = $task->getConfiguration(); + $crop = $processingConfiguration['crop'] ?? null; + if (!isset($crop)) { + return null; + } + + if (!$crop instanceof Area) { + throw new NoCloudinaryTransformation('Crop is not of type area', 1712760863923); + } + + return new Crop( + CropMode::CROP, + (int)$crop->getWidth(), + (int)$crop->getHeight(), + null, + (int)$crop->getOffsetLeft(), + (int)$crop->getOffsetTop(), + ); + } + + protected function getScaleTransformation(TaskInterface $task): ?Scale + { + $processingConfiguration = $task->getConfiguration(); + + $setProperties = array_filter(array_intersect_key($processingConfiguration, array_flip(['width', 'height']))); + $maxProperties = array_filter(array_intersect_key($processingConfiguration, array_flip(['maxWidth', 'maxHeight']))); + $minProperties = array_filter(array_intersect_key($processingConfiguration, array_flip(['minWidth', 'minHeight']))); + + if (!empty($minProperties)) { + throw new NoCloudinaryTransformation('min* is not yet supported', 1712780611488); + } + if (empty(array_merge($maxProperties, $setProperties))) { + return null; + } + + foreach ($maxProperties as $propertyName => $value) { + $equivalentSetPropertyValue = sprintf('%sm', (int)$value); + $equivalentSetPropertyName = strtolower(str_replace('max', '', $propertyName)); + if (($setProperties[$equivalentSetPropertyName] ?? '') === $equivalentSetPropertyValue) { + unset($setProperties[$equivalentSetPropertyName]); + } + } + + if (!(empty($setProperties) || empty($maxProperties))) { + throw new NoCloudinaryTransformation('max* and width/height at the same time is not yet supported', 1712780615883); + } + + if (!empty($maxProperties)) { + $scaleTransformation = [ + 'crop' => count($maxProperties) >= 2 ? CropMode::FIT : CropMode:: FILL, + ]; + foreach ($maxProperties as $key => $value) { + $scaleTransformation[strtolower(str_replace('max', '', $key))] = (int)$value; + } + + return Scale::fromParams($scaleTransformation); + } + + $scaleTransformation = [ + 'crop' => CropMode::SCALE, + ]; + + foreach (['width', 'height'] as $property) { + $stringValue = $processingConfiguration[$property] ?? ''; + preg_match('/^(\d*)(c)?/', $stringValue, $matches); + $pixels = $matches[1] ?? 0; + if ((int)$pixels > 0) { + $scaleTransformation[$property] = $pixels; + } + if ($matches[2] === 'c') { + $scaleTransformation['crop'] = CropMode::FILL; + } + } + + return Scale::fromParams($scaleTransformation); + } +} \ No newline at end of file diff --git a/Classes/Slots/FileProcessingSlot.php b/Classes/Slots/FileProcessingSlot.php deleted file mode 100644 index ca25c6e..0000000 --- a/Classes/Slots/FileProcessingSlot.php +++ /dev/null @@ -1,81 +0,0 @@ -isProcessed()) { - return; - } - - if (strpos($processedFile->getIdentifier() ?? '', 'PROCESSEDFILE' ) === 0) { - return; - } - - $options = [ - 'type' => 'upload', - 'eager' => [ - [ - //'format' => 'jpg', // `Invalid transformation component - auto` - 'fetch_format' => 'auto', - 'quality' => 'auto:eco', - 'width' => 64, - 'height' => 64, - 'crop' => 'fit', - ] - ] - ]; - - $explicitData = $this->getCloudinaryImageService()->getExplicitData($file, $options); - $url = $explicitData['eager'][0]['secure_url']; - - $parts = parse_url($url); - $processedFile->setName(basename($url)); - $processedFile->setIdentifier('PROCESSEDFILE' . $parts['path']); - - $processedFile->updateProperties([ - 'width' => $explicitData['eager'][0]['width'], - 'height' => $explicitData['eager'][0]['height'], - ]); - - /** @var $processedFileRepository ProcessedFileRepository */ - $processedFileRepository = GeneralUtility::makeInstance(ProcessedFileRepository::class); - $processedFileRepository->add($processedFile); - } - - /** - * @return object|CloudinaryImageService - */ - public function getCloudinaryImageService() - { - return GeneralUtility::makeInstance(CloudinaryImageService::class); - } - -} diff --git a/Classes/Utility/CloudinaryApiUtility.php b/Classes/Utility/CloudinaryApiUtility.php index 9329aaf..db67c09 100644 --- a/Classes/Utility/CloudinaryApiUtility.php +++ b/Classes/Utility/CloudinaryApiUtility.php @@ -8,7 +8,13 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ + +use Cloudinary\Cloudinary; +use Cloudinary\Configuration\Configuration; +use Exception; +use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; +use Visol\Cloudinary\Driver\CloudinaryDriver; use Visol\Cloudinary\Services\ConfigurationService; /** @@ -17,23 +23,48 @@ class CloudinaryApiUtility { - public static function initializeByConfiguration(array $configuration) + public static function getCloudinary(ResourceStorage|array $storage): Cloudinary { + return new Cloudinary(self::getConfiguration($storage)); + } + + public static function getConfiguration(ResourceStorage|array $storage): Configuration + { + return Configuration::instance(self::getArrayConfiguration($storage)); + } + + public static function getArrayConfiguration(ResourceStorage|array $storage): array + { + if (is_array($storage)) { + $storageConfiguration = $storage; + } else { + if ($storage->getDriverType() !== CloudinaryDriver::DRIVER_TYPE) { + // Check the file is stored on the right storage + // If not we should trigger an exception + $message = sprintf( + 'Wrong storage! Can not initialize with storage type "%s".', + $storage->getDriverType() + ); + throw new Exception($message, 1590401459); + } + $storageConfiguration = $storage->getConfiguration(); + } + /** @var ConfigurationService $configurationService */ $configurationService = GeneralUtility::makeInstance( ConfigurationService::class, - $configuration + $storageConfiguration ); - \Cloudinary::config( - [ - 'cloud_name' => $configurationService->get('cloudName'), - 'api_key' => $configurationService->get('apiKey'), - 'api_secret' => $configurationService->get('apiSecret'), - 'timeout' => $configurationService->get('timeout'), - 'secure' => true - ] - ); + return [ + 'cloud_name' => $configurationService->get('cloudName'), + 'api_key' => $configurationService->get('apiKey'), + 'api_secret' => $configurationService->get('apiSecret'), + 'timeout' => $configurationService->get('timeout'), + 'secure' => true, + 'private_cdn' => $configurationService->get('cname') !== '', + 'secure_distribution' => $configurationService->get('cname'), + ]; } } diff --git a/Classes/Utility/CloudinaryFileUtility.php b/Classes/Utility/CloudinaryFileUtility.php new file mode 100644 index 0000000..388f317 --- /dev/null +++ b/Classes/Utility/CloudinaryFileUtility.php @@ -0,0 +1,37 @@ + 'text/plain', + 'htm' => 'text/html', + 'html' => 'text/html', + 'php' => 'text/html', + 'css' => 'text/css', + 'js' => 'text/javascript', + 'csv' => 'text/comma-separated-values', + 'ics' => 'text/calendar', + 'log' => 'text/x-log', + 'zsh' => 'text/x-scriptzsh', + 'rtx' => 'text/richtext', + 'srt' => 'text/srt', + 'vcf' => 'text/x-vcard', + 'vtt' => 'text/vtt', + 'xsl' => 'text/xsl', + + // images + 'png' => 'image/png', + 'jpe' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'ico' => 'image/vnd.microsoft.icon', + 'tiff' => 'image/tiff', + 'tif' => 'image/tiff', + 'svg' => 'image/svg+xml', + 'svgz' => 'image/svg+xml', + 'json' => 'text/json', + 'cdr' => 'image/cdr', + + // audio + 'mp3' => 'audio/mpeg', + 'qt' => 'video/quicktime', + 'aac' => 'audio/x-acc', + 'ac3' => 'audio/ac3', + 'aif' => 'audio/aiff', + 'au' => 'audio/x-au', + 'flac' => 'audio/x-flac', + 'm4a' => 'audio/x-m4a', + 'mid' => 'audio/midi', + 'ra' => 'audio/x-realaudio', + 'ram' => 'audio/x-pn-realaudio', + 'rpm' => 'audio/x-pn-realaudio-plugin', + 'wma' => 'audio/x-ms-wma', + + // video + 'youtube' => 'video/youtube', + 'vimeo' => 'video/vimeo', + 'mov' => 'video/quicktime', + 'movie' => 'video/x-sgi-movie', + 'mp4' => 'video/mp4', + 'mpeg' => 'video/mpeg', + 'ogg' => 'video/ogg', + 'rv' => 'video/vnd.rn-realvideo', + 'webm' => 'video/webm', + 'wmv' => 'video/x-ms-wmv', + '3g2' => 'video/3gpp2', + '3gp' => 'video/3gp', + 'avi' => 'video/avi', + 'f4v' => 'video/x-f4v', + 'flv' => 'video/x-flv', + 'jp2' => 'video/mj2', + + // adobe + 'pdf' => 'application/pdf', + 'psd' => 'image/vnd.adobe.photoshop', + 'ai' => 'application/postscript', + 'eps' => 'application/postscript', + 'ps' => 'application/postscript', + 'xml' => 'application/xml', + 'swf' => 'application/x-shockwave-flash', + + // ms office + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'rtf' => 'application/rtf', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.ms-excel', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // open office + 'odt' => 'application/vnd.oasis.opendocument.text', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + 'odp' => 'application/vnd.oasis.opendocument.presentation', + + // archives + 'zip' => 'application/zip', + 'rar' => 'application/x-rar-compressed', + 'exe' => 'application/x-msdownload', + 'msi' => 'application/x-msdownload', + 'cab' => 'application/vnd.ms-cab-compressed', + + // other + '7zip' => 'application/x-compressed', + 'cpt' => 'application/mac-compactpro', + 'dcr' => 'application/x-director', + 'dvi' => 'application/x-dvi', + 'gpg' => 'application/gpg-keys', + 'gtar' => 'application/x-gtar', + 'gzip' => 'application/x-gzip', + 'kml' => 'application/vnd.google-earth.kml+xml', + 'kmz' => 'application/vnd.google-earth.kmz', + 'm4u' => 'application/vnd.mpegurl', + 'mif' => 'application/vnd.mif', + 'p10' => 'application/pkcs10', + 'p12' => 'application/x-pkcs12', + 'p7a' => 'application/x-pkcs7-signature', + 'p7c' => 'application/pkcs7-mime', + 'p7r' => 'application/x-pkcs7-certreqresp', + 'p7s' => 'application/pkcs7-signature', + 'pem' => 'application/x-pem-file', + 'pgp' => 'application/pgp', + 'sit' => 'application/x-stuffit', + 'smil' => 'application/smil', + 'tar' => 'application/x-tar', + 'tgz' => 'application/x-gzip-compressed', + 'vlc' => 'application/videolan', + 'wbxml' => 'application/wbxml', + 'wmlc' => 'application/wmlc', + 'xhtml' => 'application/xhtml+xml', + 'xl' => 'application/excel', + 'xspf' => 'application/xspf+xml', + 'z' => 'application/x-compress', + + ]; + + return array_key_exists($fileExtension, $mimeTypes) + ? $mimeTypes[$fileExtension] + : 'application/octet-stream'; + } +} diff --git a/Classes/ViewHelpers/CloudinaryImageDataViewHelper.php b/Classes/ViewHelpers/CloudinaryImageDataViewHelper.php index 1652044..16bf77d 100644 --- a/Classes/ViewHelpers/CloudinaryImageDataViewHelper.php +++ b/Classes/ViewHelpers/CloudinaryImageDataViewHelper.php @@ -8,10 +8,15 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ + +use Exception; +use InvalidArgumentException; +use RuntimeException; use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; +use UnexpectedValueException; use Visol\Cloudinary\Services\CloudinaryImageService; use Visol\Cloudinary\Services\CloudinaryPathService; use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; @@ -134,7 +139,7 @@ public function render(): string 'medianImage' => $this->getCloudinaryImageService()->getImage($breakpoints, 'median'), 'maxImage' => $this->getCloudinaryImageService()->getImage($breakpoints, 'max'), ]; - } catch (\Exception $e) { + } catch (Exception $e) { $responsiveImageData = [ 'images' => [ 1 => [ @@ -151,11 +156,11 @@ public function render(): string } } catch (ResourceDoesNotExistException $e) { // thrown if file does not exist - } catch (\UnexpectedValueException $e) { + } catch (UnexpectedValueException $e) { // thrown if a file has been replaced with a folder - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { // RuntimeException thrown if a file is outside of a storage - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { // thrown if file storage does not exist } @@ -178,7 +183,7 @@ protected function getCloudinaryPathService(ResourceStorage $storage) { return GeneralUtility::makeInstance( CloudinaryPathService::class, - $storage->getConfiguration() + $storage ); } diff --git a/Classes/ViewHelpers/CloudinaryImageViewHelper.php b/Classes/ViewHelpers/CloudinaryImageViewHelper.php index 9db76b5..7c507e8 100644 --- a/Classes/ViewHelpers/CloudinaryImageViewHelper.php +++ b/Classes/ViewHelpers/CloudinaryImageViewHelper.php @@ -8,9 +8,14 @@ * For the full copyright and license information, please read the * LICENSE.md file that was distributed with this source code. */ + +use Exception; +use InvalidArgumentException; +use RuntimeException; use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper; +use UnexpectedValueException; use Visol\Cloudinary\Services\CloudinaryImageService; use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; use TYPO3\CMS\Core\Resource\FileInterface; @@ -120,7 +125,7 @@ public function render(): string $this->tag->addAttribute('sizes', $cloudinarySizes); $this->tag->addAttribute('srcset', $cloudinarySrcset); $this->tag->addAttribute('src', $image->getPublicUrl()); - } catch (\Exception $e) { + } catch (Exception $e) { $this->tag->addAttribute('src', $image->getPublicUrl()); } @@ -134,11 +139,11 @@ public function render(): string } } catch (ResourceDoesNotExistException $e) { // thrown if file does not exist - } catch (\UnexpectedValueException $e) { + } catch (UnexpectedValueException $e) { // thrown if a file has been replaced with a folder - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { // RuntimeException thrown if a file is outside of a storage - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { // thrown if file storage does not exist } diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php index 9413ccc..2fb09ab 100644 --- a/Configuration/Backend/AjaxRoutes.php +++ b/Configuration/Backend/AjaxRoutes.php @@ -1,8 +1,10 @@ [ 'path' => '/cloudinary/add-files', - 'target' => \Visol\Cloudinary\Controller\CloudinaryAjaxController::class . '::addFilesAction', + 'target' => CloudinaryAjaxController::class . '::addFilesAction', ], ]; diff --git a/Configuration/FlexForm/CloudinaryFlexForm.xml b/Configuration/FlexForm/CloudinaryFlexForm.xml index 0a5d5e5..5a5cb5e 100644 --- a/Configuration/FlexForm/CloudinaryFlexForm.xml +++ b/Configuration/FlexForm/CloudinaryFlexForm.xml @@ -46,6 +46,14 @@ + + + + + input + + + diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 7acc094..e514373 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -7,6 +7,13 @@ services: Visol\Cloudinary\: resource: '../Classes/*' + Visol\Cloudinary\Command\CloudinaryApiCommand: + tags: + - name: 'console.command' + command: 'cloudinary:api' + schedulable: false + description: Interact with cloudinary api + Visol\Cloudinary\Command\CloudinaryCopyCommand: tags: - name: 'console.command' @@ -28,10 +35,16 @@ services: schedulable: false description: Run a suite of Acceptance Tests. + Visol\Cloudinary\Command\CloudinaryFixImageDimensionCommand: + tags: + - name: 'console.command' + command: 'cloudinary:fix:image:dimension' + description: Fix missing width and height of image. + Visol\Cloudinary\Command\CloudinaryFixJpegCommand: tags: - name: 'console.command' - command: 'cloudinary:fix' + command: 'cloudinary:fix:image:after-move' schedulable: false description: After "moving" files you should fix the jpeg extension. Consult README.md for more info. @@ -42,9 +55,29 @@ services: schedulable: false description: Scan and warm up a cloudinary storage. + Visol\Cloudinary\Command\CloudinaryStorageListCommand: + tags: + - name: 'console.command' + command: 'cloudinary:storage:list' + schedulable: false + description: Will display all available storages configured for cloudinary + + Visol\Cloudinary\Command\CloudinaryMetadataCommand: + tags: + - name: 'console.command' + command: 'cloudinary:metadata' + schedulable: false + description: Set metadata on cloudinary resources such as file reference and file usage. + Visol\Cloudinary\Command\CloudinaryQueryCommand: tags: - name: 'console.command' command: 'cloudinary:query' schedulable: false description: Query a given storage such a list, count files or folders. + + Visol\Cloudinary\EventHandlers\BeforeFileProcessingEventHandler: + tags: + - name: event.listener + identifier: 'cloudinary-before-file-processing-event-handler' + event: TYPO3\CMS\Core\Resource\Event\BeforeFileProcessingEvent diff --git a/Configuration/TypoScript/setup.typoscript b/Configuration/TypoScript/setup.webhook.example.typoscript similarity index 75% rename from Configuration/TypoScript/setup.typoscript rename to Configuration/TypoScript/setup.webhook.example.typoscript index 757cf4b..bb27603 100644 --- a/Configuration/TypoScript/setup.typoscript +++ b/Configuration/TypoScript/setup.webhook.example.typoscript @@ -9,7 +9,6 @@ page_1573555440 { xhtml_cleaning = 0 admPanel = 0 disableAllHeaderCode = 1 - additionalHeaders.10.header = Content-type:text/html } 10 = COA_INT 10 { @@ -18,10 +17,13 @@ page_1573555440 { userFunc = TYPO3\CMS\Extbase\Core\Bootstrap->run vendorName = Visol extensionName = Cloudinary - pluginName = Cache + pluginName = WebHook + settings { + storage = ### !!! Add a storage uid + } switchableControllerActions { - CloudinaryScan { - 1 = scan + CloudinaryWebHook { + 1 = process } } } diff --git a/Documentation/backend-cloudinary-integration-01.png b/Documentation/backend-cloudinary-integration-01.png new file mode 100644 index 0000000..6c7581b Binary files /dev/null and b/Documentation/backend-cloudinary-integration-01.png differ diff --git a/Documentation/driver-configuration-02.png b/Documentation/driver-configuration-02.png deleted file mode 100644 index 3001b7f..0000000 Binary files a/Documentation/driver-configuration-02.png and /dev/null differ diff --git a/Documentation/driver-configuration-03.png b/Documentation/driver-configuration-03.png new file mode 100644 index 0000000..4c54e79 Binary files /dev/null and b/Documentation/driver-configuration-03.png differ diff --git a/Documentation/extension-configuration-01.png b/Documentation/extension-configuration-01.png new file mode 100644 index 0000000..af213bb Binary files /dev/null and b/Documentation/extension-configuration-01.png differ diff --git a/Makefile b/Makefile index 2a60cfe..5fcd547 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,14 @@ lint-summary: lint-fix: phpcbf +## phpstan analyse +phpstan: + php -d memory_limit=512M ./vendor/bin/phpstan analyse -c phpstan.neon + +## phpstan adjust baseline +phpstan-baseline: + php -d memory_limit=512M ./vendor/bin/phpstan analyse -c phpstan.neon --generate-baseline + ####################### # PHPUnit ####################### diff --git a/README.md b/README.md index 81e98a0..8bba5cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A TYPO3 extension that connect TYPO3 with [Cloudinary](cloudinary.com) services by the means of a **Cloudinary Driver for FAL**. The extension also provides various View Helpers to render images on the Frontend. Cloudinary is a service provider dealing with images and videos. -It offers various services among other: +It offers various services among others: * CDN for fast images and videos delivering * Manipulation of images and videos such as cropping, resizing and much more... @@ -44,19 +44,62 @@ For a new "file storage" record, then: * Pick the **Cloudinary** driver in the driver dropdown menu. * Fill in the requested fields. Password and secrets can be found from the [Cloudinary Console](https://cloudinary.com/console). - -![](Documentation/driver-configuration-02.png) - Once the record is saved, you should see a message telling the connection could be successfully established. You can now head to the File module list. Notice the first time you click on a folder in the File list module, it will take some time since the images must be fetched and put into the cloudinary cache. Notice you can also use environment variable to configure the storage. -The environment variable should be surrounded by %. Example `%BUCKET_NAME%` +The environment variable should be surrounded by %. Example `%env(BUCKET_NAME%)` ![](Documentation/driver-configuration-01.png) +Cloudinary integration as file picker +------------------------------------- + +The extension is providing an integration with Cloudinary so that the editor can directly interact with the cloudinary library in the backend. +When clicking on a button, a modal window will open up displaying the Cloudinary files directly. +From there, the files can be inserted directly as file references. + +![](Documentation/backend-cloudinary-integration-01.png) + +To enable this button, we should first configure the extension settings to display the +desired button and storage. + +![](Documentation/extension-configuration-01.png) + + +We can even take it a step further by enabling auto-login. +A new field called authenticationEmail has been added to the storage configuration. +By providing a configured email in Cloudinary, we can automatically log +in when clicking on the button. Magic! + +![](Documentation/driver-configuration-03.png) + + +Configuration TCEFORM +--------------------- + +We can configure the form in the backend to hide the default TYPO3 button, +thus limiting backend user interaction with the Cloudinary library. +Here is an example of such a configuration: + +``` +TCEFORM { + pages { + media { + config { + appearance { + fileUploadAllowed = 0 + fileByUrlAllowed = 0 + elementBrowserEnabled = 0 + } + } + } + } +} +``` + Logging ------- @@ -69,7 +112,7 @@ tail -f public/typo3temp/var/logs/cloudinary.log To decide: we now have log level INFO. We might consider "increasing" the level to "DEBUG". -Caveats and trouble shooting +Caveats and troubleshooting ---------------------------- * **Free** Cloudinary account allows 500 API request per day @@ -156,7 +199,7 @@ many resources at once. cld sync --push localFolder remoteFolder ``` -The extension provides also a tool to copy a bunch of files (restricted to images) from one storage to an another. +The extension provides also a tool to copy a bunch of files (restricted to images) from one storage to another. This can be achieved with this command: ```shell script @@ -164,7 +207,7 @@ This can be achieved with this command: # where 1 is the source storage (local) # and 2 is the target storage (cloudinary) -# Ouptut: +# Output: Copying 64 files from storage "fileadmin/ (auto-created)" (1) to "Cloudinary Storage (fabidule)" (2) Copying /introduction/images/typo3-book-backend-login.png Copying /introduction/images/content/content-quote.png @@ -198,20 +241,30 @@ Available targets: Web Hook -------- -Whenever uploading or editing a file through the Cloudinary Manager you can configure an URL -as a web hook to be called to invalidate the cache in TYPO3. -This is highly recommended to keep the data consistent between Cloudinary and TYPO3. + +Whenever uploading or editing a file in the cloudinary library, you can configure in the cloudinary settings a URL to +be called as a web hook. This is recommended to keep the data consistent between Cloudinary and TYPO3. When overriding +or moving a file across folders, cloudinary will inform TYPO3 that something has changed. + +It will basically: + +* invalidate the processed files +* invalidate the page cache where the file is involved. + ```shell script https://domain.tld/?type=1573555440 ``` -**Beware**: Do not rename, move or delete files in the Cloudinary Media Library. TYPO3 will not know about the change. -We may need to implement a web hook. For now, it is necessary to perform these action in the File module in the Backend. +This, however, will not work out of the box and requires some manual configuration. +Refer to the file ext:cloudinary/Configuration/TypoScript/setup.typoscript where we define a custom type. +This is an example TypoScript file. Make sure that the file is loaded, and that you have defined a storage UID. +Your system may contain multiple Cloudinary storages, and each web hook must refer to its own Cloudinary storage. +Eventually you will end up having as many config as you have cloudinary storage. Source of inspiration --------------------- -Adapter for theleague php flysystem for Cloudinary +Adapter for php flysystem for Cloudinary https://github.com/flownative/flow-google-cloudstorage diff --git a/Resources/Private/Language/backend.xlf b/Resources/Private/Language/backend.xlf index 50a64ed..da9a4c9 100644 --- a/Resources/Private/Language/backend.xlf +++ b/Resources/Private/Language/backend.xlf @@ -14,10 +14,13 @@ Cloudinary API Secret - A possible base path to store files in a parent directory. Example `production/` + A possible base path for storing files in a parent directory on cloudinary. Example `production/` + + + A possible CNAME to serve your assets from, instead of the default res.cloudinary.com domain name. - Email used for authentication (auto-login) in file reference fields in TCEForm. + Email address used for authentication (auto-login) in file reference fields within TCEForm. Cloudinary API timeout @@ -31,9 +34,6 @@ Congratulations! Cloudinary is successfully connected to TYPO3. - - Cloudinary resources - diff --git a/Resources/Private/Standalone/MediaLibrary/Show.html b/Resources/Private/Standalone/MediaLibrary/Show.html index 747c9cd..f49a42f 100644 --- a/Resources/Private/Standalone/MediaLibrary/Show.html +++ b/Resources/Private/Standalone/MediaLibrary/Show.html @@ -1 +1,12 @@ - + + + \ No newline at end of file diff --git a/Resources/Public/JavaScript/CloudinaryMediaLibrary.js b/Resources/Public/JavaScript/CloudinaryMediaLibrary.js index 5de0398..4484f96 100644 --- a/Resources/Public/JavaScript/CloudinaryMediaLibrary.js +++ b/Resources/Public/JavaScript/CloudinaryMediaLibrary.js @@ -6,134 +6,102 @@ define([ 'TYPO3/CMS/Backend/Utility/MessageUtility', 'TYPO3/CMS/Backend/Modal', 'TYPO3/CMS/Backend/Severity', + '//media-library.cloudinary.com/global/all.js', ], function ($, NProgress, MessageUtility, Modal, Severity) { - let irreNewTimout; - let irreToggleTimout; + let cloudinaryButtons = Array.from(document.getElementsByClassName('btn-cloudinary-media-library')); + setCloudinaryButtonEvent(cloudinaryButtons); - // Click "new" irre $('.t3js-create-new-button').click(function(e) { - const numberOfIrreObjects = $(this).parents('.form-group').find('.form-irre-object').length; - irreNewTimout = setTimeout(isNewIrreElementReady, 300, this, numberOfIrreObjects) + setTimedOutedCloudinaryButtonEvent(); }) - // Detect if the "new" irre is ready - function isNewIrreElementReady(element, numberOfIrreObjects) { - - const _numberOfIrreObjects = $(element).parents('.form-group').find('.form-irre-object').length; - - if (_numberOfIrreObjects > numberOfIrreObjects) { - clearTimeout(irreNewTimout) - initializeCloudinaryButtons() - } else { - irreToggleTimout = setTimeout(isNewIrreElementReady, 100, element, numberOfIrreObjects) - } - } - - // Click "toggle" irre box - $('.form-irre-header-cell').click(function(e) { - irreToggleTimout = setTimeout(isEditIrreElementReady, 300, this) + $('.form-irre-header-button').click(function(e) { + setTimedOutedCloudinaryButtonEvent(); }) - // Detect if the "toggle" irre is ready - function isEditIrreElementReady(element) { - // Detect if the element is ready to be used - const childElement = $(element).parents('div[data-object-uid]').find('.panel-collapse .tab-content') - if (childElement.length) { - clearTimeout(irreToggleTimout) - initializeCloudinaryButtons() - } else { - irreToggleTimout = setTimeout(isEditIrreElementReady, 100, element) - } + function setTimedOutedCloudinaryButtonEvent(){ + setTimeout(() => + {setCloudinaryButtonEvent(Array.from(document.getElementsByClassName('btn-cloudinary-media-library')))}, + 1000 + ); } - function initializeCloudinaryButtons () { - $('.btn-cloudinary-media-library[data-is-initialized="0"]').map((index, element) => { - const cloudinaryCredentials = Array.isArray($(element).data('cloudinaryCredentials')) - ? $(element).data('cloudinaryCredentials') - : [] - - cloudinaryCredentials.map((credential) => { - // Render the "select image or video" button - const mediaLibrary = cloudinary.createMediaLibrary( - { - cloud_name: credential.cloudName, - api_key: credential.apiKey, - username: credential.username, - timestamp: credential.timestamp, - signature: credential.signature, - button_class: - 'btn btn-default open-btn mx-1 btn-open-cloudinary btn-open-cloudinary-storage-' + credential.storageUid, - button_caption: ` - - - - Image or video from "${credential.storageName}"`, // todo translate me! - // search: { expression: 'resource_type:image' }, // todo we could have video, how to filter _processed_file - }, - { - // showHandler: function () {}, - insertHandler: function (data) { - console.log(NProgress) - NProgress.start(); - - const me = this; - const cloudinaryIds = data.assets.map((asset) => { - return asset.public_id; - }); + function setCloudinaryButtonEvent(cloudinaryButtons) { + cloudinaryButtons.map((cloudinaryButton) => { + cloudinaryButton.addEventListener("click", function(event){ + event.preventDefault(); + let buttonClasses = $(this).attr('class'); + let buttonInnerHtml = $(this).prop("innerHTML"); + let objectGroup = $(this).data('objectGroup'); + let elementId = $(this).attr('id'); + openMediaLibrary(JSON.parse(cloudinaryButton.dataset.cloudinaryCredentials), objectGroup, elementId, buttonClasses, buttonInnerHtml); + }); + cloudinaryButton.removeAttribute('disabled'); + }); + } - $.post( - TYPO3.settings.ajaxUrls['cloudinary_add_files'], - { - cloudinaryIds: cloudinaryIds, - storageUid: me.storageUid, - }, - function (data) { - if (data.result === 'ok') { - data.files.map((fileUid) => { - MessageUtility.MessageUtility.send({ - actionName: 'typo3:foreignRelation:insert', - objectGroup: me.objectGroup, - table: 'sys_file', - uid: fileUid, - }); - }); + function openMediaLibrary(credential, objectGroup, elementId, buttonClasses, buttonInnerHtml) { + // Render the cloudinary button + const mediaLibrary = cloudinary.openMediaLibrary( + { + cloud_name: credential.cloudName, + api_key: credential.apiKey, + username: credential.username, + timestamp: credential.timestamp, + signature: credential.signature, + button_class: buttonClasses, + button_caption: buttonInnerHtml, + }, + { + insertHandler: function (data) { + NProgress.start(); - NProgress.done(); - } else { - // error! - const $confirm = Modal.confirm('ERROR', data.error, Severity.error, [ - { - text: 'OK', // TYPO3.lang['file_upload.button.ok'] - btnClass: 'btn-' + Severity.getCssClass(Severity.error), - name: 'ok', - active: true, - }, - ]).on('confirm.button.ok', function () { - $confirm.modal('hide'); - }); - } + const me = this; + const cloudinaryIds = data.assets.map((asset) => { + return asset.public_id; + }); - NProgress.done(); - } - ); + $.post( + TYPO3.settings.ajaxUrls['cloudinary_add_files'], + { + cloudinaryIds: cloudinaryIds, + storageUid: me.storageUid, }, - }, - '#' + $(element).attr('id') - ); - mediaLibrary.storageUid = credential.storageUid; - mediaLibrary.objectGroup = $(element).data('objectGroup'); - }); + function (data) { + if (data.result === 'ok') { + data.files.map((fileUid) => { + MessageUtility.MessageUtility.send({ + actionName: 'typo3:foreignRelation:insert', + objectGroup: me.objectGroup, + table: 'sys_file', + uid: fileUid, + }); + }); - // We update the "initialized" flag so that we don't have many buttons initialized - $(element).attr('data-is-initialized', "1") - console.log('Cloudinary button initialized!') - }) - } + NProgress.done(); + } else { + // error! + const $confirm = Modal.confirm('ERROR', data.error, Severity.error, [ + { + text: 'OK', // TYPO3.lang['file_upload.button.ok'] + btnClass: 'btn-' + Severity.getCssClass(Severity.error), + name: 'ok', + active: true, + }, + ]).on('confirm.button.ok', function () { + $confirm.modal('hide'); + }); + } - // We trigger a rendering for the normal case - initializeCloudinaryButtons () + NProgress.done(); + } + ); + }, + }, + '#' + elementId + ); + mediaLibrary.storageUid = credential.storageUid; + mediaLibrary.objectGroup = objectGroup; + } }); diff --git a/Tests/Acceptance/FileOperation/AbstractCloudinaryFileOperationTest.php b/Tests/Acceptance/FileOperation/AbstractCloudinaryFileOperationTest.php index 33f0841..5b88667 100644 --- a/Tests/Acceptance/FileOperation/AbstractCloudinaryFileOperationTest.php +++ b/Tests/Acceptance/FileOperation/AbstractCloudinaryFileOperationTest.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; +use RuntimeException; use Symfony\Component\Console\Style\SymfonyStyle; use TYPO3\CMS\Core\Resource\Folder; use TYPO3\CMS\Core\Resource\ResourceStorage; @@ -135,7 +137,7 @@ protected function getFilePath(string $fileName): string $filePath = realpath($this->fixtureDirectory . DIRECTORY_SEPARATOR . $fileName); if (!$filePath) { - throw new \RuntimeException('Missing file ' . $fileName, 1591703650); + throw new RuntimeException('Missing file ' . $fileName, 1591703650); } return $filePath; } @@ -210,14 +212,14 @@ protected function getFolder($folderIdentifier): Folder * @param bool $expression * @param string $message * - * @throws \Exception + * @throws Exception */ protected function assertTrue(bool $expression, string $message) { if ($expression !== true) { $message .= chr(10) . chr(10) . 'Expected value: true'; $message .= chr(10) . 'Actual value: ' . var_export($expression, true); - throw new \Exception('AssertTrue! ' . $message, 1590757845); + throw new Exception('AssertTrue! ' . $message, 1590757845); } else { $this->getIo()->success($message); } @@ -227,14 +229,14 @@ protected function assertTrue(bool $expression, string $message) * @param bool $expression * @param string $message * - * @throws \Exception + * @throws Exception */ protected function assertFalse(bool $expression, string $message) { if ($expression !== false) { $message .= chr(10) . chr(10) . 'Expected value: false'; $message .= chr(10) . 'Actual value: ' . var_export($expression, true); - throw new \Exception('AssertFalse! ' . $message, 1590757846); + throw new Exception('AssertFalse! ' . $message, 1590757846); } else { $this->getIo()->success($message); } @@ -245,14 +247,14 @@ protected function assertFalse(bool $expression, string $message) * @param $actual * @param string $message * - * @throws \Exception + * @throws Exception */ protected function assert($expected, $actual, string $message) { if ($expected !== $actual) { $message .= chr(10) . chr(10) . 'Expected value: ' . $expected; $message .= chr(10) . 'Actual value: ' . $actual; - throw new \Exception('Assert! ' . $message, 1590757847); + throw new Exception('Assert! ' . $message, 1590757847); } else { $this->getIo()->success($message); } diff --git a/Tests/Acceptance/FileOperation/AddFileOperationTest.php b/Tests/Acceptance/FileOperation/AddFileOperationTest.php index 4f1763d..d850a2e 100644 --- a/Tests/Acceptance/FileOperation/AddFileOperationTest.php +++ b/Tests/Acceptance/FileOperation/AddFileOperationTest.php @@ -2,6 +2,7 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; use TYPO3\CMS\Core\Resource\DuplicationBehavior; use TYPO3\CMS\Core\Resource\File; @@ -10,11 +11,12 @@ class AddFileOperationTest extends AbstractCloudinaryFileOperationTest /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { $fixtureFile = $this->getFilePath($this->resourceName); + $file = $this->getStorage()->addFile( $fixtureFile, $this->getContainingFolder($this->resourceName), diff --git a/Tests/Acceptance/FileOperation/CopyFileOperationTest.php b/Tests/Acceptance/FileOperation/CopyFileOperationTest.php index d771fff..3fe286d 100644 --- a/Tests/Acceptance/FileOperation/CopyFileOperationTest.php +++ b/Tests/Acceptance/FileOperation/CopyFileOperationTest.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class CopyFileOperationTest extends AbstractCloudinaryFileOperationTest { @@ -12,7 +14,7 @@ class CopyFileOperationTest extends AbstractCloudinaryFileOperationTest /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/CopyFolderOperationTests.php b/Tests/Acceptance/FileOperation/CopyFolderOperationTests.php index 5a9c44c..0a1e1b5 100644 --- a/Tests/Acceptance/FileOperation/CopyFolderOperationTests.php +++ b/Tests/Acceptance/FileOperation/CopyFolderOperationTests.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class CopyFolderOperationTests extends AbstractCloudinaryFileOperationTest { @@ -12,7 +14,7 @@ class CopyFolderOperationTests extends AbstractCloudinaryFileOperationTest /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/CountFilesInFolderOperationTests.php b/Tests/Acceptance/FileOperation/CountFilesInFolderOperationTests.php index bde450f..31ba7a2 100644 --- a/Tests/Acceptance/FileOperation/CountFilesInFolderOperationTests.php +++ b/Tests/Acceptance/FileOperation/CountFilesInFolderOperationTests.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class CountFilesInFolderOperationTests extends AbstractCloudinaryFileOperationTest { @@ -17,7 +19,7 @@ class CountFilesInFolderOperationTests extends AbstractCloudinaryFileOperationTe /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/CreateFolderOperationTests.php b/Tests/Acceptance/FileOperation/CreateFolderOperationTests.php index b9d52b9..a2b3694 100644 --- a/Tests/Acceptance/FileOperation/CreateFolderOperationTests.php +++ b/Tests/Acceptance/FileOperation/CreateFolderOperationTests.php @@ -2,12 +2,14 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class CreateFolderOperationTests extends AbstractCloudinaryFileOperationTest { /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/DeleteFileOperationTest.php b/Tests/Acceptance/FileOperation/DeleteFileOperationTest.php index 3a51b26..4f3b8ec 100644 --- a/Tests/Acceptance/FileOperation/DeleteFileOperationTest.php +++ b/Tests/Acceptance/FileOperation/DeleteFileOperationTest.php @@ -2,12 +2,14 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class DeleteFileOperationTest extends AbstractCloudinaryFileOperationTest { /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/DeleteFolderOperationTest.php b/Tests/Acceptance/FileOperation/DeleteFolderOperationTest.php index 2226cbb..49acb69 100644 --- a/Tests/Acceptance/FileOperation/DeleteFolderOperationTest.php +++ b/Tests/Acceptance/FileOperation/DeleteFolderOperationTest.php @@ -2,12 +2,14 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class DeleteFolderOperationTest extends AbstractCloudinaryFileOperationTest { /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/MoveFileOperationTest.php b/Tests/Acceptance/FileOperation/MoveFileOperationTest.php index 5588f59..7ad1ff3 100644 --- a/Tests/Acceptance/FileOperation/MoveFileOperationTest.php +++ b/Tests/Acceptance/FileOperation/MoveFileOperationTest.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class MoveFileOperationTest extends AbstractCloudinaryFileOperationTest { @@ -12,7 +14,7 @@ class MoveFileOperationTest extends AbstractCloudinaryFileOperationTest /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileOperation/RenameFolderOperationTests.php b/Tests/Acceptance/FileOperation/RenameFolderOperationTests.php index d4b0e5c..c723db7 100644 --- a/Tests/Acceptance/FileOperation/RenameFolderOperationTests.php +++ b/Tests/Acceptance/FileOperation/RenameFolderOperationTests.php @@ -2,6 +2,8 @@ namespace Visol\Cloudinary\Tests\Acceptance\FileOperation; +use Exception; + class RenameFolderOperationTests extends AbstractCloudinaryFileOperationTest { @@ -12,7 +14,7 @@ class RenameFolderOperationTests extends AbstractCloudinaryFileOperationTest /** * @return void - * @throws \Exception + * @throws Exception */ public function run() { diff --git a/Tests/Acceptance/FileTestSuite.php b/Tests/Acceptance/FileTestSuite.php index cfec724..6d2082b 100644 --- a/Tests/Acceptance/FileTestSuite.php +++ b/Tests/Acceptance/FileTestSuite.php @@ -13,21 +13,18 @@ class FileTestSuite extends AbstractCloudinaryTestSuite { - /** - * @var - */ - protected $files = [ + protected array $files = [ 'sub-folder/image-jpeg.jpeg', 'sub-folder/image-tiff.tiff', 'image-jpg.jpg', 'image-png.png', - 'document.odt', + #'document.odt', 'document.pdf', - 'video.youtube', + #'video.youtube', 'video.mp4', ]; - public function runTests() + public function runTests(): void { // Basic access file such as read, write, delete diff --git a/Tests/Acceptance/FolderTestSuite.php b/Tests/Acceptance/FolderTestSuite.php index 37b3295..a9d423b 100644 --- a/Tests/Acceptance/FolderTestSuite.php +++ b/Tests/Acceptance/FolderTestSuite.php @@ -13,19 +13,16 @@ class FolderTestSuite extends AbstractCloudinaryTestSuite { - /** - * @var - */ - protected $files = [ + protected array $files = [ 'sub-folder/image-jpeg.jpeg', 'sub-folder/image-tiff.tiff', 'sub-folder/sub-sub-folder/image-jpeg.jpeg', 'sub-folder/sub-sub-folder/image-tiff.tiff', 'image-jpg.jpg', 'image-png.png', - 'document.odt', + #'document.odt', 'document.pdf', - 'video.youtube', + #'video.youtube', 'video.mp4', ]; @@ -41,7 +38,7 @@ public function runTests() // Count files $test = new CountFilesInFolderOperationTests($this, '/'); - $test->setExpectedNumberOfFiles(6) + $test->setExpectedNumberOfFiles(4) ->setExpectedNumberOfFolders(1) // _processed_ folder must be taken into consideration in the root folder ->run(); diff --git a/composer.json b/composer.json index 35e8ea9..2b82707 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "GPL-2.0-or-later" ], "require": { - "cloudinary/cloudinary_php": "^1.15", + "cloudinary/cloudinary_php": "^2", "ext-json": "*", "php": "~8.0 || ~8.1", "typo3/cms-backend": "^11.5", diff --git a/ext_conf_template.txt b/ext_conf_template.txt index 9f64f4f..cc9625f 100644 --- a/ext_conf_template.txt +++ b/ext_conf_template.txt @@ -1,5 +1,5 @@ # cat=cloudinary; type=int; label=Default cloudinary storage where to upload "local" file (files usually stored on storage = 0) default_cloudinary_storage = 0 -# cat=cloudinary; type=string; label=CSV values of storage to be displayed in file references fields in TCForm. If empty all cloudinary file storages will be displayed. -tceform_cludinary_storage = +# cat=cloudinary; type=int; label=Cloudinary storage to be displayed in file references fields in TCForm. +tceform_cludinary_storage = 0 diff --git a/ext_emconf.php b/ext_emconf.php index 3941664..509afa9 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -1,7 +1,7 @@ \Cloudinary::class, + 'title' => Cloudinary::class, 'description' => 'Cloudinary integration in TYPO3. Use automatic breakpoint generation for images.', 'state' => 'stable', 'author' => 'Jonas Renggli', diff --git a/ext_localconf.php b/ext_localconf.php index 32683a3..45fbd51 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -1,24 +1,21 @@ ', - ); +call_user_func(callback: function () { // Override default class to add cloudinary button $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1652423292] = [ @@ -28,51 +25,60 @@ ]; ExtensionUtility::configurePlugin( - \Cloudinary::class, - 'Cache', + Cloudinary::class, + 'WebHook', [ - CloudinaryScanController::class => 'scan', + CloudinaryWebHookController::class => 'process', ], // non-cacheable actions [ - CloudinaryScanController::class => 'scan', + CloudinaryWebHookController::class => 'process', ], ); /** @var DriverRegistry $driverRegistry */ $driverRegistry = GeneralUtility::makeInstance(DriverRegistry::class); $driverRegistry->registerDriverClass( - CloudinaryFastDriver::class, - CloudinaryFastDriver::DRIVER_TYPE, - \Cloudinary::class, + CloudinaryDriver::class, + CloudinaryDriver::DRIVER_TYPE, + Cloudinary::class, 'FILE:EXT:cloudinary/Configuration/FlexForm/CloudinaryFlexForm.xml', ); - /* @var \TYPO3\CMS\Core\Resource\Index\ExtractorRegistry $metaDataExtractorRegistry */ - $metaDataExtractorRegistry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\Index\ExtractorRegistry::class); - $metaDataExtractorRegistry->registerExtractionService(\Visol\Cloudinary\Services\Extractor\CloudinaryMetaDataExtractor::class); + /* @var ExtractorRegistry $metaDataExtractorRegistry */ + $metaDataExtractorRegistry = GeneralUtility::makeInstance(ExtractorRegistry::class); + $metaDataExtractorRegistry->registerExtractionService(CloudinaryMetaDataExtractor::class); + + // Log configuration for cloudinary web hook + $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Controller']['CloudinaryWebHookController']['writerConfiguration'] = [ + LogLevel::DEBUG => [ + FileWriter::class => [ + 'logFile' => Environment::getVarPath() . '/log/cloudinary-web-hook.log' + ], + ], + + // Configuration for WARNING severity, including all + // levels with higher severity (ERROR, CRITICAL, EMERGENCY) + LogLevel::WARNING => [ + SyslogWriter::class => [], + ], + ]; - $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol'][\Cloudinary::class]['Service']['writerConfiguration'] - = $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol'][\Cloudinary::class]['Cache']['writerConfiguration'] - = $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol'][\Cloudinary::class]['Driver']['writerConfiguration'] + // Log configuration for cloudinary driver + $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Service']['writerConfiguration'] + = $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Cache']['writerConfiguration'] + = $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Driver']['writerConfiguration'] = [ // configuration for WARNING severity, including all // levels with higher severity (ERROR, CRITICAL, EMERGENCY) LogLevel::INFO => [ FileWriter::class => [ // configuration for the writer - 'logFile' => 'typo3temp/var/logs/cloudinary.log', + 'logFile' => Environment::getVarPath() . '/log/cloudinary.log', ], ], ]; - if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cloudinary'])) { - // cache configuration, see https://docs.typo3.org/typo3cms/CoreApiReference/ApiOverview/CachingFramework/Configuration/Index.html#cache-configurations - $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cloudinary']['frontend'] = VariableFrontend::class; - $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cloudinary']['groups'] = ['all', 'cloudinary']; - $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cloudinary']['options']['defaultLifetime'] = 2592000; - } - // Hook for traditional file upload, replace $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_extfilefunc.php']['processData'][] = FileUploadHook::class; diff --git a/ext_tables.sql b/ext_tables.sql index a6460dd..e57f0c4 100644 --- a/ext_tables.sql +++ b/ext_tables.sql @@ -16,9 +16,9 @@ CREATE TABLE tx_cloudinary_explicit_data_cache ( ); # -# Table structure for table 'tx_cloudinary_resource' +# Table structure for table 'tx_cloudinary_cache_resources' # -CREATE TABLE tx_cloudinary_resource ( +CREATE TABLE tx_cloudinary_cache_resources ( public_id text, public_id_hash char(40) DEFAULT '' NOT NULL, folder text, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5fed779..6d84309 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,15 +1,5 @@ parameters: ignoreErrors: - - - message: "#^Call to an undefined method object\\:\\:get\\(\\)\\.$#" - count: 7 - path: Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php - - - - message: "#^Call to an undefined method object\\:\\:getQueryBuilderForTable\\(\\)\\.$#" - count: 1 - path: Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php - - message: "#^Call to method assignMultiple\\(\\) on an unknown class TYPO3\\\\CMS\\\\Fluid\\\\View\\\\StandaloneView\\.$#" count: 1 @@ -25,11 +15,6 @@ parameters: count: 1 path: Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php - - - message: "#^Cannot call method fetchAllAssociativeIndexed\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php - - message: "#^Class TYPO3\\\\CMS\\\\Fluid\\\\View\\\\StandaloneView not found\\.$#" count: 1 @@ -55,51 +40,11 @@ parameters: count: 1 path: Classes/Backend/Form/Container/InlineCloudinaryControlContainer.php - - - message: "#^Call to an undefined method object\\:\\:getCache\\(\\)\\.$#" - count: 1 - path: Classes/Cache/CloudinaryTypo3Cache.php - - - - message: "#^Call to an undefined method object\\:\\:getLogger\\(\\)\\.$#" - count: 1 - path: Classes/Cache/CloudinaryTypo3Cache.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Cache\\\\CloudinaryTypo3Cache\\:\\:get\\(\\) should return array\\|false but returns mixed\\.$#" - count: 1 - path: Classes/Cache/CloudinaryTypo3Cache.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Cache\\\\CloudinaryTypo3Cache\\:\\:log\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Cache/CloudinaryTypo3Cache.php - - - - message: "#^PHPDoc tag @return with type mixed is not subtype of native type string\\.$#" - count: 2 - path: Classes/Cache/CloudinaryTypo3Cache.php - - - - message: "#^Call to an undefined method object\\:\\:get\\(\\)\\.$#" - count: 1 - path: Classes/CloudinaryFactory.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\CloudinaryFactory\\:\\:getFolder\\(\\) should return TYPO3\\\\CMS\\\\Core\\\\Resource\\\\Folder but returns object\\.$#" - count: 1 - path: Classes/CloudinaryFactory.php - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Resource\\\\Folder\\.$#" count: 1 path: Classes/CloudinaryFactory.php - - - message: "#^Cannot call method fetchAll\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Command/AbstractCloudinaryCommand.php - - message: "#^Else branch is unreachable because previous condition is always true\\.$#" count: 1 @@ -131,44 +76,44 @@ parameters: path: Classes/Command/AbstractCloudinaryCommand.php - - message: "#^PHPDoc tag @return with type object is not subtype of native type Doctrine\\\\DBAL\\\\Driver\\\\Connection\\.$#" - count: 1 + message: "#^Parameter \\#2 \\$string of static method TYPO3\\\\CMS\\\\Core\\\\Utility\\\\GeneralUtility\\:\\:trimExplode\\(\\) expects string, mixed given\\.$#" + count: 2 path: Classes/Command/AbstractCloudinaryCommand.php - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryAcceptanceTestCommand\\:\\:tearDown\\(\\) has no return type specified\\.$#" count: 1 - path: Classes/Command/AbstractCloudinaryCommand.php + path: Classes/Command/CloudinaryAcceptanceTestCommand.php - message: "#^Parameter \\#2 \\$string of static method TYPO3\\\\CMS\\\\Core\\\\Utility\\\\GeneralUtility\\:\\:trimExplode\\(\\) expects string, mixed given\\.$#" - count: 2 - path: Classes/Command/AbstractCloudinaryCommand.php + count: 1 + path: Classes/Command/CloudinaryAcceptanceTestCommand.php - - message: "#^Constant LF not found\\.$#" - count: 7 - path: Classes/Command/CloudinaryAcceptanceTestCommand.php + message: "#^Parameter \\#1 \\$publicId of method Cloudinary\\\\Api\\\\Admin\\\\AdminApi\\:\\:asset\\(\\) expects string, mixed given\\.$#" + count: 1 + path: Classes/Command/CloudinaryApiCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryAcceptanceTestCommand\\:\\:configure\\(\\) has no return type specified\\.$#" + message: "#^Parameter \\#1 \\$uid of method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ResourceFactory\\:\\:getStorageObject\\(\\) expects int\\|null, mixed given\\.$#" count: 1 - path: Classes/Command/CloudinaryAcceptanceTestCommand.php + path: Classes/Command/CloudinaryApiCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryAcceptanceTestCommand\\:\\:initialize\\(\\) has no return type specified\\.$#" + message: "#^Parameter \\#1 \\$value of method Cloudinary\\\\Api\\\\Search\\\\SearchApi\\:\\:nextCursor\\(\\) expects string, mixed given\\.$#" count: 1 - path: Classes/Command/CloudinaryAcceptanceTestCommand.php + path: Classes/Command/CloudinaryApiCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryAcceptanceTestCommand\\:\\:tearDown\\(\\) has no return type specified\\.$#" + message: "#^Part \\$expression \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 - path: Classes/Command/CloudinaryAcceptanceTestCommand.php + path: Classes/Command/CloudinaryApiCommand.php - - message: "#^Parameter \\#2 \\$string of static method TYPO3\\\\CMS\\\\Core\\\\Utility\\\\GeneralUtility\\:\\:trimExplode\\(\\) expects string, mixed given\\.$#" + message: "#^Variable \\$response in empty\\(\\) always exists and is not falsy\\.$#" count: 1 - path: Classes/Command/CloudinaryAcceptanceTestCommand.php + path: Classes/Command/CloudinaryApiCommand.php - message: "#^Cannot call method exists\\(\\) on TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File\\|TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ProcessedFile\\|null\\.$#" @@ -200,21 +145,11 @@ parameters: count: 1 path: Classes/Command/CloudinaryCopyCommand.php - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryCopyCommand\\:\\:configure\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Command/CloudinaryCopyCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryCopyCommand\\:\\:ensureDirectoryExistence\\(\\) has no return type specified\\.$#" count: 1 path: Classes/Command/CloudinaryCopyCommand.php - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryCopyCommand\\:\\:initialize\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Command/CloudinaryCopyCommand.php - - message: "#^Parameter \\#1 \\$fileObject of method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryCopyCommand\\:\\:download\\(\\) expects TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File, TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File\\|TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ProcessedFile\\|null given\\.$#" count: 1 @@ -241,22 +176,17 @@ parameters: path: Classes/Command/CloudinaryCopyCommand.php - - message: "#^Cannot call method fetchAll\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Command/CloudinaryFixJpegCommand.php - - - - message: "#^Method Doctrine\\\\DBAL\\\\Driver\\\\Connection\\:\\:query\\(\\) invoked with 1 parameter, 0 required\\.$#" + message: "#^Parameter \\#1 \\$uid of method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ResourceFactory\\:\\:getStorageObject\\(\\) expects int\\|null, mixed given\\.$#" count: 1 - path: Classes/Command/CloudinaryFixJpegCommand.php + path: Classes/Command/CloudinaryFixImageDimensionCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryFixJpegCommand\\:\\:configure\\(\\) has no return type specified\\.$#" + message: "#^Property Visol\\\\Cloudinary\\\\Command\\\\AbstractCloudinaryCommand\\:\\:\\$isSilent \\(bool\\) does not accept mixed\\.$#" count: 1 - path: Classes/Command/CloudinaryFixJpegCommand.php + path: Classes/Command/CloudinaryFixImageDimensionCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryFixJpegCommand\\:\\:initialize\\(\\) has no return type specified\\.$#" + message: "#^Method Doctrine\\\\DBAL\\\\Driver\\\\Connection\\:\\:query\\(\\) invoked with 1 parameter, 0 required\\.$#" count: 1 path: Classes/Command/CloudinaryFixJpegCommand.php @@ -271,37 +201,27 @@ parameters: path: Classes/Command/CloudinaryFixJpegCommand.php - - message: "#^Cannot call method exists\\(\\) on TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File\\|TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ProcessedFile\\|null\\.$#" + message: "#^Parameter \\#1 \\$fileIdentifier of method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\:\\:computeCloudinaryPublicId\\(\\) expects string, mixed given\\.$#" count: 1 - path: Classes/Command/CloudinaryMoveCommand.php - - - - message: "#^Cannot call method getIdentifier\\(\\) on TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File\\|TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ProcessedFile\\|null\\.$#" - count: 8 - path: Classes/Command/CloudinaryMoveCommand.php + path: Classes/Command/CloudinaryMetadataCommand.php - - message: "#^Constant LF not found\\.$#" - count: 1 - path: Classes/Command/CloudinaryMoveCommand.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryMoveCommand\\:\\:configure\\(\\) has no return type specified\\.$#" + message: "#^Parameter \\#1 \\$uid of method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ResourceFactory\\:\\:getStorageObject\\(\\) expects int\\|null, mixed given\\.$#" count: 1 - path: Classes/Command/CloudinaryMoveCommand.php + path: Classes/Command/CloudinaryMetadataCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryMoveCommand\\:\\:getFileMoveService\\(\\) should return Visol\\\\Cloudinary\\\\Services\\\\FileMoveService but returns object\\.$#" + message: "#^Cannot call method exists\\(\\) on TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File\\|TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ProcessedFile\\|null\\.$#" count: 1 path: Classes/Command/CloudinaryMoveCommand.php - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryMoveCommand\\:\\:initialize\\(\\) has no return type specified\\.$#" - count: 1 + message: "#^Cannot call method getIdentifier\\(\\) on TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File\\|TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ProcessedFile\\|null\\.$#" + count: 8 path: Classes/Command/CloudinaryMoveCommand.php - - message: "#^PHPDoc tag @return with type object is not subtype of native type Visol\\\\Cloudinary\\\\Services\\\\FileMoveService\\.$#" + message: "#^Constant LF not found\\.$#" count: 1 path: Classes/Command/CloudinaryMoveCommand.php @@ -345,26 +265,6 @@ parameters: count: 1 path: Classes/Command/CloudinaryMoveCommand.php - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryQueryCommand\\:\\:configure\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Command/CloudinaryQueryCommand.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryQueryCommand\\:\\:getFolder\\(\\) should return TYPO3\\\\CMS\\\\Core\\\\Resource\\\\Folder but returns object\\.$#" - count: 1 - path: Classes/Command/CloudinaryQueryCommand.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryQueryCommand\\:\\:initialize\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Command/CloudinaryQueryCommand.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Resource\\\\Folder\\.$#" - count: 1 - path: Classes/Command/CloudinaryQueryCommand.php - - message: "#^Parameter \\#1 \\$folderIdentifier of method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryQueryCommand\\:\\:getFolder\\(\\) expects string, mixed given\\.$#" count: 4 @@ -400,21 +300,6 @@ parameters: count: 1 path: Classes/Command/CloudinaryQueryCommand.php - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryScanCommand\\:\\:configure\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Command/CloudinaryScanCommand.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryScanCommand\\:\\:getCloudinaryScanService\\(\\) should return Visol\\\\Cloudinary\\\\Services\\\\CloudinaryScanService but returns object\\.$#" - count: 1 - path: Classes/Command/CloudinaryScanCommand.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Command\\\\CloudinaryScanCommand\\:\\:initialize\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Command/CloudinaryScanCommand.php - - message: "#^Parameter \\#1 \\$uid of method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\ResourceFactory\\:\\:getStorageObject\\(\\) expects int\\|null, mixed given\\.$#" count: 1 @@ -426,27 +311,17 @@ parameters: path: Classes/Controller/CloudinaryAjaxController.php - - message: "#^Call to an undefined method object\\:\\:computeFileIdentifier\\(\\)\\.$#" - count: 1 - path: Classes/Controller/CloudinaryAjaxController.php - - - - message: "#^Call to an undefined method object\\:\\:get\\(\\)\\.$#" - count: 4 - path: Classes/Controller/CloudinaryAjaxController.php - - - - message: "#^Call to an undefined method object\\:\\:save\\(\\)\\.$#" + message: "#^Cannot access offset 'cloudinaryIds' on array\\|object\\|null\\.$#" count: 1 path: Classes/Controller/CloudinaryAjaxController.php - - message: "#^Cannot access offset 'cloudinaryIds' on array\\|object\\|null\\.$#" + message: "#^Cannot access offset 'storageUid' on array\\|object\\|null\\.$#" count: 1 path: Classes/Controller/CloudinaryAjaxController.php - - message: "#^Cannot access offset 'storageUid' on array\\|object\\|null\\.$#" + message: "#^Cannot access offset 0 on mixed\\.$#" count: 1 path: Classes/Controller/CloudinaryAjaxController.php @@ -460,21 +335,6 @@ parameters: count: 1 path: Classes/Controller/CloudinaryScanController.php - - - message: "#^Call to an undefined method object\\:\\:getPropertyFromAspect\\(\\)\\.$#" - count: 2 - path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - - - message: "#^Cannot call method fetch\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - - - message: "#^Offset 'explicit_data' does not exist on array\\{options\\: mixed\\}\\.$#" - count: 1 - path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Database\\\\Connection\\.$#" count: 1 @@ -491,78 +351,38 @@ parameters: path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - message: "#^Parameter \\#1 \\$string of function sha1 expects string, string\\|false given\\.$#" - count: 1 + message: "#^Parameter \\#1 \\$json of function json_decode expects string, mixed given\\.$#" + count: 2 path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - message: "#^Property Visol\\\\Cloudinary\\\\Domain\\\\Repository\\\\ExplicitDataCacheRepository\\:\\:\\$tableName has no type specified\\.$#" + message: "#^Parameter \\#1 \\$string of function sha1 expects string, string\\|false given\\.$#" count: 1 path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - message: "#^Call to an undefined method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryDriver\\:\\:getCharsetConversion\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:flushFileCache\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:flushFolderCache\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:getCachedFiles\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:getCachedFolders\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:getLogger\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:setCachedFiles\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Call to an undefined method object\\:\\:setCachedFolders\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Cannot access offset 'format' on array\\|false\\.$#" + message: "#^Property Visol\\\\Cloudinary\\\\Domain\\\\Repository\\\\ExplicitDataCacheRepository\\:\\:\\$tableName has no type specified\\.$#" count: 1 - path: Classes/Driver/CloudinaryDriver.php + path: Classes/Domain/Repository/ExplicitDataCacheRepository.php - - message: "#^Cannot access offset 'public_id' on array\\|false\\.$#" - count: 1 + message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" + count: 3 path: Classes/Driver/CloudinaryDriver.php - - message: "#^Cannot access offset 'secure_url' on array\\|false\\.$#" + message: "#^Cannot access offset 0 on callable\\(\\)\\: mixed\\.$#" count: 1 path: Classes/Driver/CloudinaryDriver.php - - message: "#^Cannot access offset 'type' on 0\\|0\\.0\\|''\\|'0'\\|array\\{\\}\\|false\\|null\\.$#" + message: "#^Cannot access offset 1 on callable\\(\\)\\: mixed\\.$#" count: 1 path: Classes/Driver/CloudinaryDriver.php - message: "#^Cannot cast mixed to int\\.$#" - count: 1 + count: 2 path: Classes/Driver/CloudinaryDriver.php - @@ -571,7 +391,7 @@ parameters: path: Classes/Driver/CloudinaryDriver.php - - message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryDriver\\:\\:log\\(\\) has no return type specified\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryDriver\\:\\:isFolderEmpty\\(\\) should return bool but returns int\\.$#" count: 1 path: Classes/Driver/CloudinaryDriver.php @@ -580,185 +400,30 @@ parameters: count: 1 path: Classes/Driver/CloudinaryDriver.php - - - message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryDriver\\:\\:testConnection\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Negated boolean expression is always false\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Parameter \\#1 \\$cloudinaryResource of method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\:\\:computeFileIdentifier\\(\\) expects array, array\\|false given\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Parameter \\#1 \\$string of function rtrim expects string, string\\|null given\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryDriver\\:\\:\\$cloudinaryPathService \\(Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryDriver\\:\\:\\$cloudinaryTypo3Cache \\(Visol\\\\Cloudinary\\\\Cache\\\\CloudinaryTypo3Cache\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryDriver.php - - - - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" - count: 3 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryFolderPath\\(\\)\\.$#" - count: 9 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryPublicId\\(\\)\\.$#" - count: 9 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:computeFileIdentifier\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:computeFolderIdentifier\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:delete\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:getLogger\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:getResourceType\\(\\)\\.$#" - count: 5 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:guessMimeType\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:normalizeCloudinaryPath\\(\\)\\.$#" - count: 2 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Call to an undefined method object\\:\\:test\\(\\)\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Cannot access offset 0 on callable\\(\\)\\: mixed\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Cannot access offset 1 on callable\\(\\)\\: mixed\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Cannot cast mixed to int\\.$#" - count: 2 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:getFileContents\\(\\) should return string but returns string\\|false\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:isFolderEmpty\\(\\) should return bool but returns int\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:log\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:sanitizeFileName\\(\\) should return string but returns string\\|null\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Negated boolean expression is always false\\.$#" - count: 3 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Offset 'extension' does not exist on array\\\\|string\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - message: "#^Offset 'filename' does not exist on array\\\\|string\\.$#" count: 1 - path: Classes/Driver/CloudinaryFastDriver.php + path: Classes/Driver/CloudinaryDriver.php - message: "#^Offset 'type' does not exist on array\\{\\}\\.$#" count: 1 - path: Classes/Driver/CloudinaryFastDriver.php + path: Classes/Driver/CloudinaryDriver.php - message: "#^Parameter \\#1 \\$folder of method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryFolderService\\:\\:delete\\(\\) expects string, mixed given\\.$#" count: 1 - path: Classes/Driver/CloudinaryFastDriver.php + path: Classes/Driver/CloudinaryDriver.php - message: "#^Parameter \\#1 \\$publicId of method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryResourceService\\:\\:delete\\(\\) expects string, mixed given\\.$#" count: 2 - path: Classes/Driver/CloudinaryFastDriver.php + path: Classes/Driver/CloudinaryDriver.php - message: "#^Parameter \\#1 \\$string of function rtrim expects string, string\\|null given\\.$#" count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:\\$charsetConversion \\(TYPO3\\\\CMS\\\\Core\\\\Charset\\\\CharsetConverter\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:\\$cloudinaryFolderService \\(Visol\\\\Cloudinary\\\\Services\\\\CloudinaryFolderService\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:\\$cloudinaryPathService \\(Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:\\$cloudinaryResourceService \\(Visol\\\\Cloudinary\\\\Services\\\\CloudinaryResourceService\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Driver\\\\CloudinaryFastDriver\\:\\:\\$configurationService \\(Visol\\\\Cloudinary\\\\Services\\\\ConfigurationService\\) does not accept object\\.$#" - count: 1 - path: Classes/Driver/CloudinaryFastDriver.php + path: Classes/Driver/CloudinaryDriver.php - message: "#^Method Visol\\\\Cloudinary\\\\Filters\\\\RegularExpressionFilter\\:\\:filter\\(\\) should return bool but returns int\\|true\\.$#" @@ -780,41 +445,11 @@ parameters: count: 1 path: Classes/Filters/RegularExpressionFilter.php - - - message: "#^Call to an undefined method object\\:\\:delete\\(\\)\\.$#" - count: 1 - path: Classes/Hook/FileUploadHook.php - - - - message: "#^Call to an undefined method object\\:\\:getPublicIdForFile\\(\\)\\.$#" - count: 1 - path: Classes/Hook/FileUploadHook.php - - message: "#^Access to an undefined property Visol\\\\Cloudinary\\\\Services\\\\AbstractCloudinaryMediaService\\:\\:\\$explicitDataCacheRepository\\.$#" count: 2 path: Classes/Services/AbstractCloudinaryMediaService.php - - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryPublicId\\(\\)\\.$#" - count: 1 - path: Classes/Services/AbstractCloudinaryMediaService.php - - - - message: "#^Call to an undefined method object\\:\\:getLogger\\(\\)\\.$#" - count: 1 - path: Classes/Services/AbstractCloudinaryMediaService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\AbstractCloudinaryMediaService\\:\\:error\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Services/AbstractCloudinaryMediaService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\AbstractCloudinaryMediaService\\:\\:getEmergencyPlaceholderFile\\(\\) should return TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File but returns TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FileInterface\\.$#" - count: 1 - path: Classes/Services/AbstractCloudinaryMediaService.php - - message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\\\Connection\\:\\:delete\\(\\)\\.$#" count: 2 @@ -831,30 +466,10 @@ parameters: path: Classes/Services/CloudinaryFolderService.php - - message: "#^Cannot call method fetch\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryFolderService.php - - - - message: "#^Cannot call method fetchAll\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryFolderService.php - - - - message: "#^Cannot call method fetchColumn\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" + message: "#^Cannot cast mixed to int\\.$#" count: 2 path: Classes/Services/CloudinaryFolderService.php - - - message: "#^PHPDoc tag @return with type object is not subtype of native type Doctrine\\\\DBAL\\\\Driver\\\\Connection\\.$#" - count: 1 - path: Classes/Services/CloudinaryFolderService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\.$#" - count: 1 - path: Classes/Services/CloudinaryFolderService.php - - message: "#^Cannot access offset 'width' on mixed\\.$#" count: 2 @@ -900,11 +515,6 @@ parameters: count: 1 path: Classes/Services/CloudinaryImageService.php - - - message: "#^Property Visol\\\\Cloudinary\\\\Services\\\\CloudinaryImageService\\:\\:\\$explicitDataCacheRepository \\(Visol\\\\Cloudinary\\\\Domain\\\\Repository\\\\ExplicitDataCacheRepository\\) does not accept object\\.$#" - count: 1 - path: Classes/Services/CloudinaryImageService.php - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\:\\:stripBasePathFromIdentifier\\(\\) should return string but returns string\\|null\\.$#" count: 1 @@ -915,11 +525,6 @@ parameters: count: 1 path: Classes/Services/CloudinaryPathService.php - - - message: "#^Parameter \\#1 \\$string of function strtolower expects string, array\\\\|string given\\.$#" - count: 2 - path: Classes/Services/CloudinaryPathService.php - - message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\\\Connection\\:\\:delete\\(\\)\\.$#" count: 2 @@ -935,49 +540,9 @@ parameters: count: 2 path: Classes/Services/CloudinaryResourceService.php - - - message: "#^Cannot call method fetchAll\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^Cannot call method fetchAssociative\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^Cannot call method fetchColumn\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^Cannot call method fetchOne\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - message: "#^Cannot cast mixed to int\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryResourceService\\:\\:getCloudinaryFolderService\\(\\) should return Visol\\\\Cloudinary\\\\Services\\\\CloudinaryFolderService but returns object\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type Doctrine\\\\DBAL\\\\Driver\\\\Connection\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\.$#" - count: 1 - path: Classes/Services/CloudinaryResourceService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type Visol\\\\Cloudinary\\\\Services\\\\CloudinaryFolderService\\.$#" - count: 1 + count: 2 path: Classes/Services/CloudinaryResourceService.php - @@ -986,52 +551,17 @@ parameters: path: Classes/Services/CloudinaryResourceService.php - - message: "#^Call to an undefined method object\\:\\:getLogger\\(\\)\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^Cannot call method fetchColumn\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryScanService\\:\\:getCloudinaryFolderService\\(\\) should return Visol\\\\Cloudinary\\\\Services\\\\CloudinaryFolderService but returns object\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryScanService\\:\\:getCloudinaryResourceService\\(\\) should return Visol\\\\Cloudinary\\\\Services\\\\CloudinaryResourceService but returns object\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryScanService\\:\\:log\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^Negated boolean expression is always false\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\.$#" - count: 1 - path: Classes/Services/CloudinaryScanService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type Visol\\\\Cloudinary\\\\Services\\\\CloudinaryFolderService\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryScanService\\:\\:console\\(\\) has parameter \\$additionalBlankLine with no type specified\\.$#" count: 1 path: Classes/Services/CloudinaryScanService.php - - message: "#^PHPDoc tag @return with type object is not subtype of native type Visol\\\\Cloudinary\\\\Services\\\\CloudinaryResourceService\\.$#" + message: "#^Parameter \\#1 \\$value of method Cloudinary\\\\Api\\\\Search\\\\SearchApi\\:\\:nextCursor\\(\\) expects string, mixed given\\.$#" count: 1 path: Classes/Services/CloudinaryScanService.php - - message: "#^Property Visol\\\\Cloudinary\\\\Services\\\\CloudinaryScanService\\:\\:\\$cloudinaryPathService \\(Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\) does not accept object\\.$#" + message: "#^Variable \\$response in empty\\(\\) always exists and is not falsy\\.$#" count: 1 path: Classes/Services/CloudinaryScanService.php @@ -1041,67 +571,22 @@ parameters: path: Classes/Services/CloudinaryTestConnectionService.php - - message: "#^Call to an undefined method object\\:\\:getLogger\\(\\)\\.$#" - count: 1 - path: Classes/Services/CloudinaryUploadService.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryUploadService\\:\\:cleanUp\\(\\) has no return type specified\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryUploadService\\:\\:uploadLocalFile\\(\\) should return TYPO3\\\\CMS\\\\Core\\\\Resource\\\\File but returns TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FileInterface\\.$#" count: 1 path: Classes/Services/CloudinaryUploadService.php - - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\CloudinaryUploadService\\:\\:error\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Services/CloudinaryUploadService.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Services\\\\CloudinaryVideoService\\:\\:\\$defaultOptions has no type specified\\.$#" - count: 1 - path: Classes/Services/CloudinaryVideoService.php - - - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryPublicId\\(\\)\\.$#" - count: 1 - path: Classes/Services/Extractor/CloudinaryMetaDataExtractor.php - - - - message: "#^Call to an undefined method object\\:\\:get\\(\\)\\.$#" - count: 4 - path: Classes/Services/Extractor/CloudinaryMetaDataExtractor.php - - - - message: "#^Call to an undefined method object\\:\\:getResource\\(\\)\\.$#" - count: 1 - path: Classes/Services/Extractor/CloudinaryMetaDataExtractor.php - - message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\\\Connection\\:\\:update\\(\\)\\.$#" count: 1 path: Classes/Services/FileMoveService.php - - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryFolderPath\\(\\)\\.$#" - count: 1 - path: Classes/Services/FileMoveService.php - - - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryPublicId\\(\\)\\.$#" - count: 2 - path: Classes/Services/FileMoveService.php - - - - message: "#^Call to an undefined method object\\:\\:getResourceType\\(\\)\\.$#" - count: 1 - path: Classes/Services/FileMoveService.php - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\FileMoveService\\:\\:ensureDirectoryExistence\\(\\) has no return type specified\\.$#" count: 1 path: Classes/Services/FileMoveService.php - - message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\FileMoveService\\:\\:initializeApi\\(\\) has no return type specified\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\Services\\\\FileMoveService\\:\\:getCloudinaryPathService\\(\\) should return Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService but returns Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\|null\\.$#" count: 1 path: Classes/Services/FileMoveService.php @@ -1110,65 +595,15 @@ parameters: count: 1 path: Classes/Services/FileMoveService.php - - - message: "#^PHPDoc tag @return with type object is not subtype of native type Doctrine\\\\DBAL\\\\Driver\\\\Connection\\.$#" - count: 1 - path: Classes/Services/FileMoveService.php - - - - message: "#^PHPDoc tag @return with type object is not subtype of native type TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\.$#" - count: 1 - path: Classes/Services/FileMoveService.php - - - - message: "#^Property Visol\\\\Cloudinary\\\\Services\\\\FileMoveService\\:\\:\\$cloudinaryPathService \\(Visol\\\\Cloudinary\\\\Services\\\\CloudinaryPathService\\) does not accept object\\.$#" - count: 1 - path: Classes/Services/FileMoveService.php - - message: "#^Variable \\$resource in empty\\(\\) always exists and is not falsy\\.$#" count: 1 path: Classes/Services/FileMoveService.php - - message: "#^Call to an undefined method object\\:\\:add\\(\\)\\.$#" - count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^Call to an undefined method object\\:\\:getExplicitData\\(\\)\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\Utility\\\\CloudinaryFileUtility\\:\\:getTemporaryFile\\(\\) has parameter \\$storageUid with no type specified\\.$#" count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^Cannot access offset 'path' on array\\{scheme\\?\\: string, host\\?\\: string, port\\?\\: int\\<0, 65535\\>, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\|false\\.$#" - count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^Expression on left side of \\?\\? is not nullable\\.$#" - count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Slots\\\\FileProcessingSlot\\:\\:preFileProcess\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Slots\\\\FileProcessingSlot\\:\\:preFileProcess\\(\\) has parameter \\$taskType with no type specified\\.$#" - count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^PHPDoc tag @var has invalid value \\(\\$processedFileRepository ProcessedFileRepository\\)\\: Unexpected token \"\\$processedFileRepository\", expected type at offset 9$#" - count: 1 - path: Classes/Slots/FileProcessingSlot.php - - - - message: "#^Method Visol\\\\Cloudinary\\\\Utility\\\\CloudinaryApiUtility\\:\\:initializeByConfiguration\\(\\) has no return type specified\\.$#" - count: 1 - path: Classes/Utility/CloudinaryApiUtility.php + path: Classes/Utility/CloudinaryFileUtility.php - message: "#^Call to an undefined method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FileInterface\\:\\:getOriginalFile\\(\\)\\.$#" @@ -1176,42 +611,42 @@ parameters: path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Call to an undefined method object\\:\\:computeCloudinaryPublicId\\(\\)\\.$#" + message: "#^Cannot access offset 'path' on array\\{scheme\\?\\: string, host\\?\\: string, port\\?\\: int\\<0, 65535\\>, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\|false\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Call to an undefined method object\\:\\:generateOptionsFromSettings\\(\\)\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\ViewHelpers\\\\CloudinaryImageDataViewHelper\\:\\:injectImageService\\(\\) has no return type specified\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Call to an undefined method object\\:\\:getImage\\(\\)\\.$#" - count: 3 + message: "#^Parameter \\#1 \\$identifier of method TYPO3Fluid\\\\Fluid\\\\Core\\\\Variables\\\\VariableProviderInterface\\:\\:add\\(\\) expects string, mixed given\\.$#" + count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Call to an undefined method object\\:\\:getImageObjects\\(\\)\\.$#" + message: "#^Parameter \\#1 \\$identifier of method TYPO3Fluid\\\\Fluid\\\\Core\\\\Variables\\\\VariableProviderInterface\\:\\:remove\\(\\) expects string, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Call to an undefined method object\\:\\:getResponsiveBreakpointData\\(\\)\\.$#" + message: "#^Parameter \\#1 \\$src of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects string, int\\|string given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Cannot access offset 'path' on array\\{scheme\\?\\: string, host\\?\\: string, port\\?\\: int\\<0, 65535\\>, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\|false\\.$#" + message: "#^Parameter \\#1 \\$url of function parse_url expects string, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Method Visol\\\\Cloudinary\\\\ViewHelpers\\\\CloudinaryImageDataViewHelper\\:\\:injectImageService\\(\\) has no return type specified\\.$#" + message: "#^Parameter \\#2 \\$image of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FileInterface\\|TYPO3\\\\CMS\\\\Extbase\\\\Domain\\\\Model\\\\FileReference\\|null, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php - - message: "#^Parameter \\#1 \\$src of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects string, int\\|string given\\.$#" + message: "#^Parameter \\#3 \\$treatIdAsReference of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects bool, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageDataViewHelper.php @@ -1221,26 +656,21 @@ parameters: path: Classes/ViewHelpers/CloudinaryImageViewHelper.php - - message: "#^Call to an undefined method object\\:\\:generateOptionsFromSettings\\(\\)\\.$#" - count: 1 - path: Classes/ViewHelpers/CloudinaryImageViewHelper.php - - - - message: "#^Call to an undefined method object\\:\\:getResponsiveBreakpointData\\(\\)\\.$#" + message: "#^Method Visol\\\\Cloudinary\\\\ViewHelpers\\\\CloudinaryImageViewHelper\\:\\:injectImageService\\(\\) has no return type specified\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageViewHelper.php - - message: "#^Call to an undefined method object\\:\\:getSizesAttribute\\(\\)\\.$#" + message: "#^Parameter \\#1 \\$src of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects string, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageViewHelper.php - - message: "#^Call to an undefined method object\\:\\:getSrcsetAttribute\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$image of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FileInterface\\|TYPO3\\\\CMS\\\\Extbase\\\\Domain\\\\Model\\\\FileReference\\|null, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageViewHelper.php - - message: "#^Method Visol\\\\Cloudinary\\\\ViewHelpers\\\\CloudinaryImageViewHelper\\:\\:injectImageService\\(\\) has no return type specified\\.$#" + message: "#^Parameter \\#3 \\$treatIdAsReference of method TYPO3\\\\CMS\\\\Extbase\\\\Service\\\\ImageService\\:\\:getImage\\(\\) expects bool, mixed given\\.$#" count: 1 path: Classes/ViewHelpers/CloudinaryImageViewHelper.php diff --git a/phpstan.neon b/phpstan.neon index 0bca2fa..9356186 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,3 +7,12 @@ parameters: - Configuration checkMissingIterableValueType: false reportUnmatchedIgnoredErrors: false + ignoreErrors: + - + message: '#does not accept object.$#' + - + message: '#^Call to an undefined method object#' + - + message: '#^Cannot call method fetch.* on Doctrine\\DBAL\\Result\|int#' + - + message: '#should return .* but returns object.#'