diff --git a/core/BackgroundJobs/MovePreviewJob.php b/core/BackgroundJobs/MovePreviewJob.php index ff13a6f37e2..930f998a28b 100644 --- a/core/BackgroundJobs/MovePreviewJob.php +++ b/core/BackgroundJobs/MovePreviewJob.php @@ -17,24 +17,26 @@ use OC\Preview\Storage\StorageFactory; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use OCP\DB\Exception; +use OCP\DB\IResult; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IAppData; use OCP\Files\IMimeTypeDetector; use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; -use OCP\Files\NotFoundException; -use OCP\Files\SimpleFS\ISimpleFolder; use OCP\IAppConfig; +use OCP\IConfig; use OCP\IDBConnection; use Override; use Psr\Log\LoggerInterface; class MovePreviewJob extends TimedJob { private IAppData $appData; + private string $previewRootPath; public function __construct( ITimeFactory $time, private readonly IAppConfig $appConfig, + private readonly IConfig $config, private readonly PreviewMapper $previewMapper, private readonly StorageFactory $storageFactory, private readonly IDBConnection $connection, @@ -49,6 +51,7 @@ class MovePreviewJob extends TimedJob { $this->appData = $appDataFactory->get('preview'); $this->setTimeSensitivity(self::TIME_INSENSITIVE); $this->setInterval(24 * 60 * 60); + $this->previewRootPath = 'appdata_' . $this->config->getSystemValueString('instanceid') . '/preview/'; } #[Override] @@ -57,49 +60,22 @@ class MovePreviewJob extends TimedJob { return; } - $emptyHierarchicalPreviewFolders = false; - $startTime = time(); while (true) { - // Check new hierarchical preview folders first - if (!$emptyHierarchicalPreviewFolders) { - $qb = $this->connection->getQueryBuilder(); - $qb->select('*') - ->from('filecache') - ->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%/%/%/%/%/%/%/%'))) - ->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId()) - ->setMaxResults(100); - - $result = $qb->executeQuery(); - while ($row = $result->fetch()) { - $pathSplit = explode('/', $row['path']); - assert(count($pathSplit) >= 2); - $fileId = $pathSplit[count($pathSplit) - 2]; - $this->processPreviews($fileId, false); - } - } - - // And then the flat preview folder (legacy) - $emptyHierarchicalPreviewFolders = true; $qb = $this->connection->getQueryBuilder(); - $qb->select('*') + $qb->select('path') ->from('filecache') - ->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%.%'))) + // Hierarchical preview folder structure + ->where($qb->expr()->like('path', $qb->createNamedParameter($this->previewRootPath . '%/%/%/%/%/%/%/%/%'))) + // Legacy flat preview folder structure + ->orWhere($qb->expr()->like('path', $qb->createNamedParameter($this->previewRootPath . '%/%.%'))) ->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId()) ->setMaxResults(100); $result = $qb->executeQuery(); - $foundOldPreview = false; - while ($row = $result->fetch()) { - $pathSplit = explode('/', $row['path']); - assert(count($pathSplit) >= 2); - $fileId = $pathSplit[count($pathSplit) - 2]; - array_pop($pathSplit); - $this->processPreviews($fileId, true); - $foundOldPreview = true; - } + $foundPreviews = $this->processQueryResult($result); - if (!$foundOldPreview) { + if (!$foundPreviews) { break; } @@ -109,20 +85,46 @@ class MovePreviewJob extends TimedJob { } } - try { - // Delete any leftover preview directory - $this->appData->getFolder('.')->delete(); - } catch (NotFoundException) { - // ignore - } $this->appConfig->setValueBool('core', 'previewMovedDone', true); } + private function processQueryResult(IResult $result): bool { + $foundPreview = false; + $fileIds = []; + $flatFileIds = []; + while ($row = $result->fetch()) { + $pathSplit = explode('/', $row['path']); + assert(count($pathSplit) >= 2); + $fileId = (int)$pathSplit[count($pathSplit) - 2]; + if (count($pathSplit) === 11) { + // Hierarchical structure + if (!in_array($fileId, $fileIds)) { + $fileIds[] = $fileId; + } + } else { + // Flat structure + if (!in_array($fileId, $flatFileIds)) { + $flatFileIds[] = $fileId; + } + } + $foundPreview = true; + } + + foreach ($fileIds as $fileId) { + $this->processPreviews($fileId, flatPath: false); + } + + foreach ($flatFileIds as $fileId) { + $this->processPreviews($fileId, flatPath: true); + } + return $foundPreview; + } + /** * @param array $previewFolders */ - private function processPreviews(int|string $fileId, bool $simplePaths): void { - $internalPath = $this->getInternalFolder((string)$fileId, $simplePaths); + private function processPreviews(int $fileId, bool $flatPath): void { + $internalPath = $this->getInternalFolder((string)$fileId, $flatPath); $folder = $this->appData->getFolder($internalPath); /** @@ -133,7 +135,7 @@ class MovePreviewJob extends TimedJob { foreach ($folder->getDirectoryListing() as $previewFile) { $path = $fileId . '/' . $previewFile->getName(); /** @var SimpleFile $previewFile */ - $preview = Preview::fromPath($path, $this->mimeTypeDetector, $this->mimeTypeLoader); + $preview = Preview::fromPath($path, $this->mimeTypeDetector); if (!$preview) { $this->logger->error('Unable to import old preview at path.'); continue; @@ -160,23 +162,30 @@ class MovePreviewJob extends TimedJob { if (count($result) > 0) { foreach ($previewFiles as $previewFile) { + /** @var Preview $preview */ $preview = $previewFile['preview']; /** @var SimpleFile $file */ $file = $previewFile['file']; $preview->setStorageId($result[0]['storage']); $preview->setEtag($result[0]['etag']); - $preview->setSourceMimetype($result[0]['mimetype']); + $preview->setSourceMimeType($this->mimeTypeLoader->getMimetypeById((int)$result[0]['mimetype'])); try { $preview = $this->previewMapper->insert($preview); - } catch (Exception $e) { + } catch (Exception) { // We already have this preview in the preview table, skip + $qb->delete('filecache') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($file->getId()))) + ->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId()) + ->executeStatement(); continue; } try { $this->storageFactory->migratePreview($preview, $file); + $qb = $this->connection->getQueryBuilder(); $qb->delete('filecache') ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($file->getId()))) + ->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId()) ->executeStatement(); // Do not call $file->delete() as this will also delete the file from the file system } catch (\Exception $e) { @@ -184,35 +193,51 @@ class MovePreviewJob extends TimedJob { throw $e; } } + } else { + // No matching fileId, delete preview + try { + $this->connection->beginTransaction(); + foreach ($previewFiles as $previewFile) { + /** @var SimpleFile $file */ + $file = $previewFile['file']; + $file->delete(); + } + $this->connection->commit(); + } catch (Exception) { + $this->connection->rollback(); + } } - $this->deleteFolder($internalPath, $folder); + $this->deleteFolder($internalPath); } - public static function getInternalFolder(string $name, bool $simplePaths): string { - if ($simplePaths) { - return '/' . $name; + public static function getInternalFolder(string $name, bool $flatPath): string { + if ($flatPath) { + return $name; } return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name; } - private function deleteFolder(string $path, ISimpleFolder $folder): void { - $folder->delete(); - + private function deleteFolder(string $path): void { $current = $path; while (true) { + $appDataPath = $this->previewRootPath . $current; + $qb = $this->connection->getQueryBuilder(); + $qb->delete('filecache') + ->where($qb->expr()->eq('path_hash', $qb->createNamedParameter(md5($appDataPath)))) + ->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId()) + ->executeStatement(); + $current = dirname($current); if ($current === '/' || $current === '.' || $current === '') { break; } - $folder = $this->appData->getFolder($current); if (count($folder->getDirectoryListing()) !== 0) { break; } - $folder->delete(); } } } diff --git a/core/Command/Preview/ResetRenderedTexts.php b/core/Command/Preview/ResetRenderedTexts.php index c4fcabbe500..978128a609f 100644 --- a/core/Command/Preview/ResetRenderedTexts.php +++ b/core/Command/Preview/ResetRenderedTexts.php @@ -10,7 +10,6 @@ namespace OC\Core\Command\Preview; use OC\Preview\Db\Preview; use OC\Preview\PreviewService; -use OCP\Files\IMimeTypeLoader; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IAvatarManager; @@ -28,7 +27,6 @@ class ResetRenderedTexts extends Command { protected readonly IUserManager $userManager, protected readonly IAvatarManager $avatarManager, private readonly PreviewService $previewService, - private readonly IMimeTypeLoader $mimeTypeLoader, ) { parent::__construct(); } @@ -93,7 +91,7 @@ class ResetRenderedTexts extends Command { $previewsToDeleteCount = 0; foreach ($this->getPreviewsToDelete() as $preview) { - $output->writeln('Deleting preview ' . $preview->getName($this->mimeTypeLoader) . ' for fileId ' . $preview->getFileId(), OutputInterface::VERBOSITY_VERBOSE); + $output->writeln('Deleting preview ' . $preview->getName() . ' for fileId ' . $preview->getFileId(), OutputInterface::VERBOSITY_VERBOSE); $previewsToDeleteCount++; @@ -112,9 +110,9 @@ class ResetRenderedTexts extends Command { */ private function getPreviewsToDelete(): \Generator { return $this->previewService->getPreviewsForMimeTypes([ - $this->mimeTypeLoader->getId('text/plain'), - $this->mimeTypeLoader->getId('text/markdown'), - $this->mimeTypeLoader->getId('text/x-markdown'), + 'text/plain', + 'text/markdown', + 'text/x-markdown' ]); } } diff --git a/core/Migrations/Version33000Date20250819110529.php b/core/Migrations/Version33000Date20250819110529.php index 27bf9ab89b7..32a25b0e33c 100644 --- a/core/Migrations/Version33000Date20250819110529.php +++ b/core/Migrations/Version33000Date20250819110529.php @@ -37,6 +37,14 @@ class Version33000Date20250819110529 extends SimpleMigrationStep { $table->setPrimaryKey(['id']); } + if (!$schema->hasTable('preview_versions')) { + $table = $schema->createTable('preview_versions'); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20, 'unsigned' => true]); + $table->addColumn('file_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]); + $table->addColumn('version', Types::STRING, ['notnull' => true, 'default' => '', 'length' => 1024]); + $table->setPrimaryKey(['id']); + } + if (!$schema->hasTable('previews')) { $table = $schema->createTable('previews'); $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20, 'unsigned' => true]); @@ -46,18 +54,18 @@ class Version33000Date20250819110529 extends SimpleMigrationStep { $table->addColumn('location_id', Types::BIGINT, ['notnull' => false, 'length' => 20, 'unsigned' => true]); $table->addColumn('width', Types::INTEGER, ['notnull' => true, 'unsigned' => true]); $table->addColumn('height', Types::INTEGER, ['notnull' => true, 'unsigned' => true]); - $table->addColumn('mimetype', Types::INTEGER, ['notnull' => true]); - $table->addColumn('source_mimetype', Types::INTEGER, ['notnull' => true]); + $table->addColumn('mimetype_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('source_mimetype_id', Types::INTEGER, ['notnull' => true]); $table->addColumn('max', Types::BOOLEAN, ['notnull' => true, 'default' => false]); $table->addColumn('cropped', Types::BOOLEAN, ['notnull' => true, 'default' => false]); $table->addColumn('encrypted', Types::BOOLEAN, ['notnull' => true, 'default' => false]); $table->addColumn('etag', Types::STRING, ['notnull' => true, 'length' => 40, 'fixed' => true]); $table->addColumn('mtime', Types::INTEGER, ['notnull' => true, 'unsigned' => true]); $table->addColumn('size', Types::INTEGER, ['notnull' => true, 'unsigned' => true]); - $table->addColumn('version', Types::BIGINT, ['notnull' => true, 'default' => -1]); // can not be null otherwise unique index doesn't work + $table->addColumn('version_id', Types::BIGINT, ['notnull' => true, 'default' => -1]); $table->setPrimaryKey(['id']); $table->addIndex(['file_id']); - $table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype', 'cropped', 'version'], 'previews_file_uniq_idx'); + $table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype_id', 'cropped', 'version_id'], 'previews_file_uniq_idx'); } return $schema; diff --git a/lib/private/Files/Cache/LocalRootScanner.php b/lib/private/Files/Cache/LocalRootScanner.php index 79908d63fe2..d5f7d40e1b6 100644 --- a/lib/private/Files/Cache/LocalRootScanner.php +++ b/lib/private/Files/Cache/LocalRootScanner.php @@ -10,6 +10,7 @@ namespace OC\Files\Cache; use OCP\IConfig; use OCP\Server; +use Override; class LocalRootScanner extends Scanner { private string $previewFolder; @@ -20,6 +21,7 @@ class LocalRootScanner extends Scanner { $this->previewFolder = 'appdata_' . $config->getSystemValueString('instanceid', '') . '/preview'; } + #[Override] public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = null, $lock = true, $data = null) { if ($this->shouldScanPath($file)) { return parent::scanFile($file, $reuseExisting, $parentId, $cacheData, $lock, $data); @@ -28,6 +30,7 @@ class LocalRootScanner extends Scanner { } } + #[Override] public function scan($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $lock = true) { if ($this->shouldScanPath($path)) { return parent::scan($path, $recursive, $reuse, $lock); @@ -36,11 +39,16 @@ class LocalRootScanner extends Scanner { } } - private function shouldScanPath(string $path): bool { - $path = trim($path, '/'); + #[Override] + protected function scanChildren(string $path, $recursive, int $reuse, int $folderId, bool $lock, int|float $oldSize, &$etagChanged = false) { if (str_starts_with($path, $this->previewFolder)) { - return false; + return 0; } + return parent::scanChildren($path, $recursive, $reuse, $folderId, $lock, $oldSize, $etagChanged); + } + + private function shouldScanPath(string $path): bool { + $path = trim($path, '/'); return $path === '' || str_starts_with($path, 'appdata_') || str_starts_with($path, '__groupfolders'); } } diff --git a/lib/private/Preview/BackgroundCleanupJob.php b/lib/private/Preview/BackgroundCleanupJob.php index 257bebb1d5d..f6122fd0e12 100644 --- a/lib/private/Preview/BackgroundCleanupJob.php +++ b/lib/private/Preview/BackgroundCleanupJob.php @@ -35,7 +35,7 @@ class BackgroundCleanupJob extends TimedJob { public function run($argument): void { foreach ($this->getDeletedFiles() as $fileId) { $previewIds = []; - foreach ($this->previewService->getAvailablePreviewForFile($fileId) as $preview) { + foreach ($this->previewService->getAvailablePreviewsForFile($fileId) as $preview) { $this->previewService->deletePreview($preview); } } diff --git a/lib/private/Preview/Db/Preview.php b/lib/private/Preview/Db/Preview.php index 7eb03e1e289..d3b3ab5ad07 100644 --- a/lib/private/Preview/Db/Preview.php +++ b/lib/private/Preview/Db/Preview.php @@ -13,8 +13,6 @@ namespace OC\Preview\Db; use OCP\AppFramework\Db\Entity; use OCP\DB\Types; use OCP\Files\IMimeTypeDetector; -use OCP\Files\IMimeTypeLoader; -use OCP\Server; /** * Preview entity mapped to the oc_previews and oc_preview_locations table. @@ -35,10 +33,10 @@ use OCP\Server; * @method void setHeight(int $height) * @method bool isCropped() Get whether the preview is cropped or not. * @method void setCropped(bool $cropped) - * @method void setMimetype(int $mimetype) Set the mimetype of the preview. - * @method int getMimetype() Get the mimetype of the preview. - * @method void setSourceMimetype(int $sourceMimetype) Set the mimetype of the source file. - * @method int getSourceMimetype() Get the mimetype of the source file. + * @method void setMimetypeId(int $mimetype) Set the mimetype of the preview. + * @method int getMimetypeId() Get the mimetype of the preview. + * @method void setSourceMimetypeId(int $sourceMimetype) Set the mimetype of the source file. + * @method int getSourceMimetypeId() Get the mimetype of the source file. * @method int getMtime() Get the modification time of the preview. * @method void setMtime(int $mtime) * @method int getSize() Get the size of the preview. @@ -47,8 +45,8 @@ use OCP\Server; * @method void setMax(bool $max) * @method string getEtag() Get the etag of the preview. * @method void setEtag(string $etag) - * @method int|null getVersion() Get the version for files_versions_s3 - * @method void setVersion(?int $version) + * @method string|null getVersion() Get the version for files_versions_s3 + * @method void setVersionId(int $versionId) * @method bool|null getIs() Get the version for files_versions_s3 * @method bool isEncrypted() Get whether the preview is encrypted. At the moment every preview is unencrypted. * @method void setEncrypted(bool $encrypted) @@ -64,15 +62,17 @@ class Preview extends Entity { protected ?string $objectStoreName = null; protected ?int $width = null; protected ?int $height = null; - protected ?int $mimetype = null; - - protected ?int $sourceMimetype = null; + protected ?int $mimetypeId = null; + protected ?int $sourceMimetypeId = null; + protected string $mimetype = 'application/octet-stream'; + protected string $sourceMimetype = 'application/octet-stream'; protected ?int $mtime = null; protected ?int $size = null; protected ?bool $max = null; protected ?bool $cropped = null; protected ?string $etag = null; - protected ?int $version = null; + protected ?string $version = null; + protected ?int $versionId = null; protected ?bool $encrypted = null; public function __construct() { @@ -82,23 +82,23 @@ class Preview extends Entity { $this->addType('locationId', Types::BIGINT); $this->addType('width', Types::INTEGER); $this->addType('height', Types::INTEGER); - $this->addType('mimetype', Types::INTEGER); - $this->addType('sourceMimetype', Types::INTEGER); + $this->addType('mimetypeId', Types::INTEGER); + $this->addType('sourceMimetypeId', Types::INTEGER); $this->addType('mtime', Types::INTEGER); $this->addType('size', Types::INTEGER); $this->addType('max', Types::BOOLEAN); $this->addType('cropped', Types::BOOLEAN); $this->addType('encrypted', Types::BOOLEAN); $this->addType('etag', Types::STRING); - $this->addType('version', Types::BIGINT); + $this->addType('versionId', Types::STRING); } - public static function fromPath(string $path, IMimeTypeDetector $mimeTypeDetector, IMimeTypeLoader $mimeTypeLoader): Preview|false { + public static function fromPath(string $path, IMimeTypeDetector $mimeTypeDetector): Preview|false { $preview = new self(); $preview->setFileId((int)basename(dirname($path))); $fileName = pathinfo($path, PATHINFO_FILENAME) . '.' . pathinfo($path, PATHINFO_EXTENSION); - $ok = preg_match('/(([0-9]+)-)?([0-9]+)-([0-9]+)(-(max))?(-(crop))?\.([a-z]{3,4})/', $fileName, $matches); + $ok = preg_match('/(([A-Za-z0-9\+\/]+)-)?([0-9]+)-([0-9]+)(-(max))?(-(crop))?\.([a-z]{3,4})/', $fileName, $matches); if ($ok !== 1) { return false; @@ -108,11 +108,11 @@ class Preview extends Entity { 2 => $version, 3 => $width, 4 => $height, - 6 => $crop, - 8 => $max, + 6 => $max, + 8 => $crop, ] = $matches; - $preview->setMimetype($mimeTypeLoader->getId($mimeTypeDetector->detectPath($fileName))); + $preview->setMimeType($mimeTypeDetector->detectPath($fileName)); $preview->setWidth((int)$width); $preview->setHeight((int)$height); @@ -120,12 +120,12 @@ class Preview extends Entity { $preview->setMax($max === 'max'); if (!empty($version)) { - $preview->setVersion((int)$version); + $preview->setVersion($version); } return $preview; } - public function getName(IMimeTypeLoader $mimeTypeLoader): string { + public function getName(): string { $path = ($this->getVersion() > -1 ? $this->getVersion() . '-' : '') . $this->getWidth() . '-' . $this->getHeight(); if ($this->isCropped()) { $path .= '-crop'; @@ -134,13 +134,13 @@ class Preview extends Entity { $path .= '-max'; } - $ext = $this->getExtension($mimeTypeLoader); + $ext = $this->getExtension(); $path .= '.' . $ext; return $path; } - public function getExtension(IMimeTypeLoader $mimeTypeLoader): string { - return match ($this->getMimetypeValue($mimeTypeLoader)) { + public function getExtension(): string { + return match ($this->getMimeType()) { 'image/png' => 'png', 'image/gif' => 'gif', 'image/jpeg' => 'jpg', @@ -149,10 +149,6 @@ class Preview extends Entity { }; } - public function getMimetypeValue(IMimeTypeLoader $mimeTypeLoader): string { - return $mimeTypeLoader->getMimetypeById($this->mimetype) ?? 'image/jpeg'; - } - public function setBucketName(string $bucketName): void { $this->bucketName = $bucketName; } @@ -160,4 +156,24 @@ class Preview extends Entity { public function setObjectStoreName(string $objectStoreName): void { $this->objectStoreName = $objectStoreName; } + + public function setVersion(?string $version): void { + $this->version = $version; + } + + public function getMimeType(): string { + return $this->mimetype; + } + + public function setMimeType(string $mimeType): void { + $this->mimetype = $mimeType; + } + + public function getSourceMimeType(): string { + return $this->sourceMimetype; + } + + public function setSourceMimeType(string $mimeType): void { + $this->sourceMimetype = $mimeType; + } } diff --git a/lib/private/Preview/Db/PreviewMapper.php b/lib/private/Preview/Db/PreviewMapper.php index 7d399dc5f3d..e6ca2e720f3 100644 --- a/lib/private/Preview/Db/PreviewMapper.php +++ b/lib/private/Preview/Db/PreviewMapper.php @@ -9,13 +9,13 @@ declare(strict_types=1); namespace OC\Preview\Db; -use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\IMimeTypeLoader; use OCP\IDBConnection; -use OCP\IPreview; +use Override; /** * @template-extends QBMapper @@ -24,18 +24,74 @@ class PreviewMapper extends QBMapper { private const TABLE_NAME = 'previews'; private const LOCATION_TABLE_NAME = 'preview_locations'; + private const VERSION_TABLE_NAME = 'preview_versions'; public function __construct( IDBConnection $db, + private readonly IMimeTypeLoader $mimeTypeLoader, ) { parent::__construct($db, self::TABLE_NAME, Preview::class); } + protected function mapRowToEntity(array $row): Entity { + $row['mimetype'] = $this->mimeTypeLoader->getMimetypeById((int)$row['mimetype_id']); + $row['source_mimetype'] = $this->mimeTypeLoader->getMimetypeById((int)$row['source_mimetype_id']); + + return parent::mapRowToEntity($row); + } + + #[Override] + public function insert(Entity $entity): Entity { + /** @var Preview $preview */ + $preview = $entity; + + $preview->setMimetypeId($this->mimeTypeLoader->getId($preview->getMimeType())); + $preview->setSourceMimetypeId($this->mimeTypeLoader->getId($preview->getSourceMimeType())); + + if ($preview->getVersion() !== null && $preview->getVersion() !== '') { + $qb = $this->db->getQueryBuilder(); + $qb->insert(self::VERSION_TABLE_NAME) + ->values([ + 'version' => $preview->getVersion(), + 'file_id' => $preview->getFileId(), + ]) + ->executeStatement(); + $entity->setVersionId($qb->getLastInsertId()); + } + return parent::insert($preview); + } + + #[Override] + public function update(Entity $entity): Entity { + /** @var Preview $preview */ + $preview = $entity; + + $preview->setMimetypeId($this->mimeTypeLoader->getId($preview->getMimeType())); + $preview->setSourceMimetypeId($this->mimeTypeLoader->getId($preview->getSourceMimeType())); + + return parent::update($preview); + } + + #[Override] + public function delete(Entity $entity): Entity { + /** @var Preview $preview */ + $preview = $entity; + if ($preview->getVersion() !== null && $preview->getVersion() !== '') { + $qb = $this->db->getQueryBuilder(); + $qb->delete(self::VERSION_TABLE_NAME) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($preview->getFileId()))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter($preview->getVersion()))) + ->executeStatement(); + } + + return parent::delete($entity); + } + /** * @return \Generator * @throws Exception */ - public function getAvailablePreviewForFile(int $fileId): \Generator { + public function getAvailablePreviewsForFile(int $fileId): \Generator { $selectQb = $this->db->getQueryBuilder(); $this->joinLocation($selectQb) ->where($selectQb->expr()->eq('p.file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); @@ -82,10 +138,13 @@ class PreviewMapper extends QBMapper { } protected function joinLocation(IQueryBuilder $qb): IQueryBuilder { - return $qb->select('p.*', 'l.bucket_name', 'l.object_store_name') + return $qb->select('p.*', 'l.bucket_name', 'l.object_store_name', 'v.version') ->from(self::TABLE_NAME, 'p') - ->leftJoin('p', 'preview_locations', 'l', $qb->expr()->eq( + ->leftJoin('p', self::LOCATION_TABLE_NAME, 'l', $qb->expr()->eq( 'p.location_id', 'l.id' + )) + ->leftJoin('p', self::VERSION_TABLE_NAME, 'v', $qb->expr()->eq( + 'p.version_id', 'v.id' )); } @@ -127,15 +186,15 @@ class PreviewMapper extends QBMapper { } /** - * @param int[] $mimeTypes + * @param string[] $mimeTypes * @return \Generator */ public function getPreviewsForMimeTypes(array $mimeTypes): \Generator { $qb = $this->db->getQueryBuilder(); $this->joinLocation($qb) ->where($qb->expr()->orX( - ...array_map(function (int $mimeType) use ($qb) { - return $qb->expr()->eq('source_mimetype', $qb->createNamedParameter($mimeType, IQueryBuilder::PARAM_INT)); + ...array_map(function (string $mimeType) use ($qb): string { + return $qb->expr()->eq('source_mimetype_id', $qb->createNamedParameter($this->mimeTypeLoader->getId($mimeType), IQueryBuilder::PARAM_INT)); }, $mimeTypes) )); return $this->yieldEntities($qb); diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 664448b7d01..82c4ec88363 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -12,7 +12,6 @@ use OC\Preview\Storage\PreviewFile; use OC\Preview\Storage\StorageFactory; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\File; -use OCP\Files\IMimeTypeLoader; use OCP\Files\InvalidPathException; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; @@ -39,7 +38,6 @@ class Generator { private LoggerInterface $logger, private PreviewMapper $previewMapper, private StorageFactory $storageFactory, - private IMimeTypeLoader $mimeTypeLoader, ) { } @@ -111,9 +109,9 @@ class Generator { [$file->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$file->getId()]); - $previewVersion = -1; + $previewVersion = null; if ($file instanceof IVersionedPreviewFile) { - $previewVersion = (int)$file->getPreviewVersion(); + $previewVersion = $file->getPreviewVersion(); } // Get the max preview and infer the max preview sizes from that @@ -152,7 +150,7 @@ class Generator { // No need to generate a preview that is just the max preview if ($width === $maxWidth && $height === $maxHeight) { // ensure correct return value if this was the last one - $previewFile = new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader); + $previewFile = new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper); continue; } @@ -163,14 +161,14 @@ class Generator { && $preview->getVersion() === $previewVersion && $preview->isCropped() === $crop); if ($preview) { - $previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader); + $previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper); } else { if (!$this->previewManager->isMimeSupported($mimeType)) { throw new NotFoundException(); } if ($maxPreviewImage === null) { - $maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader)); + $maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper)); } $this->logger->debug('Cached preview not found for file {path}, generating a new preview.', ['path' => $file->getPath()]); @@ -298,7 +296,7 @@ class Generator { * @param Preview[] $previews * @throws NotFoundException */ - private function getMaxPreview(array $previews, File $file, string $mimeType, int $version): Preview { + private function getMaxPreview(array $previews, File $file, string $mimeType, ?string $version): Preview { // We don't know the max preview size, so we can't use getCachedPreview. // It might have been generated with a higher resolution than the current value. foreach ($previews as $preview) { @@ -313,7 +311,7 @@ class Generator { return $this->generateProviderPreview($file, $maxWidth, $maxHeight, false, true, $mimeType, $version); } - private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, int $version): Preview { + private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, ?string $version): Preview { $previewProviders = $this->previewManager->getProviders(); foreach ($previewProviders as $supportedMimeType => $providers) { // Filter out providers that does not support this mime @@ -353,14 +351,14 @@ class Generator { $previewEntry = new Preview(); $previewEntry->setFileId($file->getId()); $previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId()); - $previewEntry->setSourceMimetype($this->mimeTypeLoader->getId($file->getMimeType())); + $previewEntry->setSourceMimeType($file->getMimeType()); $previewEntry->setWidth($preview->width()); $previewEntry->setHeight($preview->height()); $previewEntry->setVersion($version); $previewEntry->setMax($max); $previewEntry->setCropped($crop); $previewEntry->setEncrypted(false); - $previewEntry->setMimetype($this->mimeTypeLoader->getId($preview->dataMimeType())); + $previewEntry->setMimetype($preview->dataMimeType()); $previewEntry->setEtag($file->getEtag()); $previewEntry->setMtime((new \DateTime())->getTimestamp()); $previewEntry->setSize(0); @@ -468,7 +466,7 @@ class Generator { bool $crop, int $maxWidth, int $maxHeight, - ?int $version, + ?string $version, bool $cacheResult, ): ISimpleFile { $preview = $maxPreview; @@ -508,21 +506,21 @@ class Generator { $previewEntry->setFileId($file->getId()); $previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId()); $previewEntry->setWidth($width); - $previewEntry->setSourceMimetype($this->mimeTypeLoader->getId($file->getMimeType())); + $previewEntry->setSourceMimeType($file->getMimeType()); $previewEntry->setHeight($height); $previewEntry->setVersion($version); $previewEntry->setMax(false); $previewEntry->setCropped($crop); $previewEntry->setEncrypted(false); - $previewEntry->setMimetype($this->mimeTypeLoader->getId($preview->dataMimeType())); + $previewEntry->setMimeType($preview->dataMimeType()); $previewEntry->setEtag($file->getEtag()); $previewEntry->setMtime((new \DateTime())->getTimestamp()); $previewEntry->setSize(0); if ($cacheResult) { $previewEntry = $this->savePreview($previewEntry, $preview); - return new PreviewFile($previewEntry, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader); + return new PreviewFile($previewEntry, $this->storageFactory, $this->previewMapper); } else { - return new InMemoryFile($previewEntry->getName($this->mimeTypeLoader), $preview->data()); + return new InMemoryFile($previewEntry->getName(), $preview->data()); } } @@ -540,7 +538,10 @@ class Generator { if ($preview instanceof IStreamImage) { $size = $this->storageFactory->writePreview($previewEntry, $preview->resource()); } else { - $size = $this->storageFactory->writePreview($previewEntry, $preview->data()); + $stream = fopen('php://temp', 'w+'); + fwrite($stream, $preview->data()); + rewind($stream); + $size = $this->storageFactory->writePreview($previewEntry, $stream); } if (!$size) { throw new \RuntimeException('Unable to write preview file'); diff --git a/lib/private/Preview/PreviewService.php b/lib/private/Preview/PreviewService.php index 0e7c66dc12d..8d30ae8a402 100644 --- a/lib/private/Preview/PreviewService.php +++ b/lib/private/Preview/PreviewService.php @@ -70,12 +70,12 @@ class PreviewService { /** * @return \Generator */ - public function getAvailablePreviewForFile(int $fileId): \Generator { - return $this->previewMapper->getAvailablePreviewForFile($fileId); + public function getAvailablePreviewsForFile(int $fileId): \Generator { + return $this->previewMapper->getAvailablePreviewsForFile($fileId); } /** - * @param int[] $mimeTypes + * @param string[] $mimeTypes * @return \Generator */ public function getPreviewsForMimeTypes(array $mimeTypes): \Generator { diff --git a/lib/private/Preview/Storage/IPreviewStorage.php b/lib/private/Preview/Storage/IPreviewStorage.php index 56464326838..1d6b128f8f0 100644 --- a/lib/private/Preview/Storage/IPreviewStorage.php +++ b/lib/private/Preview/Storage/IPreviewStorage.php @@ -10,32 +10,44 @@ declare(strict_types=1); namespace OC\Preview\Storage; +use Exception; use OC\Files\SimpleFS\SimpleFile; use OC\Preview\Db\Preview; +use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; interface IPreviewStorage { /** - * @param resource|string $stream + * @param resource $stream * @throws NotPermittedException + * @throws NotFoundException */ - public function writePreview(Preview $preview, mixed $stream): false|int; + public function writePreview(Preview $preview, mixed $stream): int; /** * @param Preview $preview - * @return resource|false + * @return resource + * @throws NotPermittedException + * @throws NotFoundException */ public function readPreview(Preview $preview): mixed; + /** + * @throws NotPermittedException + */ public function deletePreview(Preview $preview): void; /** * Migration helper * * To remove at some point - * @throw \Exception + * @throws Exception */ public function migratePreview(Preview $preview, SimpleFile $file): void; + /** + * @throws NotPermittedException + * @throws NotFoundException + */ public function scan(): int; } diff --git a/lib/private/Preview/Storage/LocalPreviewStorage.php b/lib/private/Preview/Storage/LocalPreviewStorage.php index e522df7da8e..bd5e1a97818 100644 --- a/lib/private/Preview/Storage/LocalPreviewStorage.php +++ b/lib/private/Preview/Storage/LocalPreviewStorage.php @@ -17,7 +17,8 @@ use OC\Preview\Db\Preview; use OC\Preview\Db\PreviewMapper; use OCP\DB\Exception; use OCP\Files\IMimeTypeDetector; -use OCP\Files\IMimeTypeLoader; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\IAppConfig; use OCP\IConfig; use OCP\IDBConnection; @@ -33,10 +34,8 @@ class LocalPreviewStorage implements IPreviewStorage { public function __construct( private readonly IConfig $config, private readonly PreviewMapper $previewMapper, - private readonly StorageFactory $previewStorage, private readonly IAppConfig $appConfig, private readonly IDBConnection $connection, - private readonly IMimeTypeLoader $mimeTypeLoader, private readonly IMimeTypeDetector $mimeTypeDetector, private readonly LoggerInterface $logger, ) { @@ -45,22 +44,28 @@ class LocalPreviewStorage implements IPreviewStorage { } #[Override] - public function writePreview(Preview $preview, mixed $stream): false|int { + public function writePreview(Preview $preview, mixed $stream): int { $previewPath = $this->constructPath($preview); - if (!$this->createParentFiles($previewPath)) { - return false; - } + $this->createParentFiles($previewPath); return file_put_contents($previewPath, $stream); } #[Override] public function readPreview(Preview $preview): mixed { - return @fopen($this->constructPath($preview), 'r'); + $previewPath = $this->constructPath($preview); + $resource = @fopen($previewPath, 'r'); + if ($resource === false) { + throw new NotFoundException('Unable to open preview stream at ' . $previewPath); + } + return $resource; } #[Override] public function deletePreview(Preview $preview): void { - @unlink($this->constructPath($preview)); + $previewPath = $this->constructPath($preview); + if (!@unlink($previewPath) && is_file($previewPath)) { + throw new NotPermittedException('Unable to delete preview at ' . $previewPath); + } } public function getPreviewRootFolder(): string { @@ -68,19 +73,21 @@ class LocalPreviewStorage implements IPreviewStorage { } private function constructPath(Preview $preview): string { - return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName($this->mimeTypeLoader); + return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName(); } - private function createParentFiles(string $path): bool { + private function createParentFiles(string $path): void { $dirname = dirname($path); @mkdir($dirname, recursive: true); - return is_dir($dirname); + if (!is_dir($dirname)) { + throw new NotPermittedException("Unable to create directory '$dirname'"); + } } #[Override] public function migratePreview(Preview $preview, SimpleFile $file): void { // legacy flat directory - $sourcePath = $this->getPreviewRootFolder() . $preview->getFileId() . '/' . $preview->getName($this->mimeTypeLoader); + $sourcePath = $this->getPreviewRootFolder() . $preview->getFileId() . '/' . $preview->getName(); if (!file_exists($sourcePath)) { return; } @@ -106,7 +113,7 @@ class LocalPreviewStorage implements IPreviewStorage { $previewsFound = 0; foreach (new RecursiveIteratorIterator($scanner) as $file) { if ($file->isFile()) { - $preview = Preview::fromPath((string)$file, $this->mimeTypeDetector, $this->mimeTypeLoader); + $preview = Preview::fromPath((string)$file, $this->mimeTypeDetector); if ($preview === false) { $this->logger->error('Unable to parse preview information for ' . $file->getRealPath()); continue; diff --git a/lib/private/Preview/Storage/ObjectStorePreviewStorage.php b/lib/private/Preview/Storage/ObjectStorePreviewStorage.php index 88470802667..3e4337fbf28 100644 --- a/lib/private/Preview/Storage/ObjectStorePreviewStorage.php +++ b/lib/private/Preview/Storage/ObjectStorePreviewStorage.php @@ -15,6 +15,7 @@ use OC\Files\ObjectStore\PrimaryObjectStoreConfig; use OC\Files\SimpleFS\SimpleFile; use OC\Preview\Db\Preview; use OC\Preview\Db\PreviewMapper; +use OCP\Files\NotPermittedException; use OCP\Files\ObjectStore\IObjectStore; use OCP\IConfig; use Override; @@ -41,15 +42,7 @@ class ObjectStorePreviewStorage implements IPreviewStorage { } #[Override] - public function writePreview(Preview $preview, mixed $stream): false|int { - if (!is_resource($stream)) { - $fh = fopen('php://temp', 'w+'); - fwrite($fh, $stream); - rewind($fh); - - $stream = $fh; - } - + public function writePreview(Preview $preview, mixed $stream): int { $size = 0; $countStream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void { $size = $writtenSize; @@ -61,7 +54,11 @@ class ObjectStorePreviewStorage implements IPreviewStorage { 'config' => $config, ] = $this->getObjectStoreForPreview($preview); - $store->writeObject($this->constructUrn($objectPrefix, $preview->getId()), $countStream); + try { + $store->writeObject($this->constructUrn($objectPrefix, $preview->getId()), $countStream); + } catch (\Exception $exception) { + throw new NotPermittedException('Unable to save preview to object store', previous: $exception); + } return $size; } @@ -71,7 +68,11 @@ class ObjectStorePreviewStorage implements IPreviewStorage { 'objectPrefix' => $objectPrefix, 'store' => $store, ] = $this->getObjectStoreForPreview($preview); - return $store->readObject($this->constructUrn($objectPrefix, $preview->getId())); + try { + return $store->readObject($this->constructUrn($objectPrefix, $preview->getId())); + } catch (\Exception $exception) { + throw new NotPermittedException('Unable to read preview from object store', previous: $exception); + } } #[Override] @@ -80,7 +81,11 @@ class ObjectStorePreviewStorage implements IPreviewStorage { 'objectPrefix' => $objectPrefix, 'store' => $store, ] = $this->getObjectStoreForPreview($preview); - $store->deleteObject($this->constructUrn($objectPrefix, $preview->getId())); + try { + $store->deleteObject($this->constructUrn($objectPrefix, $preview->getId())); + } catch (\Exception $exception) { + throw new NotPermittedException('Unable to delete preview from object store', previous: $exception); + } } #[Override] diff --git a/lib/private/Preview/Storage/PreviewFile.php b/lib/private/Preview/Storage/PreviewFile.php index c7c7b59c97c..64cafb45470 100644 --- a/lib/private/Preview/Storage/PreviewFile.php +++ b/lib/private/Preview/Storage/PreviewFile.php @@ -12,7 +12,6 @@ namespace OC\Preview\Storage; use OC\Preview\Db\Preview; use OC\Preview\Db\PreviewMapper; -use OCP\Files\IMimeTypeLoader; use OCP\Files\SimpleFS\ISimpleFile; use Override; @@ -21,13 +20,12 @@ class PreviewFile implements ISimpleFile { private readonly Preview $preview, private readonly IPreviewStorage $storage, private readonly PreviewMapper $previewMapper, - private readonly IMimeTypeLoader $mimeTypeLoader, ) { } #[Override] public function getName(): string { - return $this->preview->getName($this->mimeTypeLoader); + return $this->preview->getName(); } #[Override] @@ -63,12 +61,12 @@ class PreviewFile implements ISimpleFile { #[Override] public function getMimeType(): string { - return $this->preview->getMimetypeValue($this->mimeTypeLoader); + return $this->preview->getMimetype(); } #[Override] public function getExtension(): string { - return $this->preview->getExtension($this->mimeTypeLoader); + return $this->preview->getExtension(); } #[Override] diff --git a/lib/private/Preview/Storage/StorageFactory.php b/lib/private/Preview/Storage/StorageFactory.php index e33135be3ce..b15031c6a12 100644 --- a/lib/private/Preview/Storage/StorageFactory.php +++ b/lib/private/Preview/Storage/StorageFactory.php @@ -25,7 +25,7 @@ class StorageFactory implements IPreviewStorage { } #[Override] - public function writePreview(Preview $preview, mixed $stream): false|int { + public function writePreview(Preview $preview, mixed $stream): int { return $this->getBackend()->writePreview($preview, $stream); } diff --git a/lib/private/Preview/Watcher.php b/lib/private/Preview/Watcher.php index ea0f72796ae..9b95e87d2ed 100644 --- a/lib/private/Preview/Watcher.php +++ b/lib/private/Preview/Watcher.php @@ -13,6 +13,7 @@ use OC\Preview\Storage\StorageFactory; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\Node; +use OCP\IDBConnection; /** * Class Watcher @@ -26,8 +27,9 @@ class Watcher { * Watcher constructor. */ public function __construct( - readonly private StorageFactory $storageFactory, - readonly private PreviewMapper $previewMapper, + private readonly StorageFactory $storageFactory, + private readonly PreviewMapper $previewMapper, + private readonly IDBConnection $connection, ) { } @@ -47,8 +49,14 @@ class Watcher { } [$node->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$nodeId]); - foreach ($previews as $preview) { - $this->storageFactory->deletePreview($preview); + $this->connection->beginTransaction(); + try { + foreach ($previews as $preview) { + $this->storageFactory->deletePreview($preview); + $this->previewMapper->delete($preview); + } + } finally { + $this->connection->commit(); } } diff --git a/lib/private/PreviewManager.php b/lib/private/PreviewManager.php index 480408d7724..fb884092872 100644 --- a/lib/private/PreviewManager.php +++ b/lib/private/PreviewManager.php @@ -16,7 +16,6 @@ use OC\Preview\Storage\StorageFactory; use OCP\AppFramework\QueryException; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\File; -use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; @@ -140,7 +139,6 @@ class PreviewManager implements IPreview { $this->container->get(LoggerInterface::class), $this->container->get(PreviewMapper::class), $this->container->get(StorageFactory::class), - $this->container->get(IMimeTypeLoader::class), ); } return $this->generator; diff --git a/lib/private/Server.php b/lib/private/Server.php index 97483844333..927d2ce3224 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -308,6 +308,7 @@ class Server extends ServerContainer implements IServerContainer { return new Watcher( $c->get(\OC\Preview\Storage\StorageFactory::class), $c->get(PreviewMapper::class), + $c->get(IDBConnection::class), ); }); diff --git a/lib/public/Preview/IVersionedPreviewFile.php b/lib/public/Preview/IVersionedPreviewFile.php index 7d68fe8d15e..842ae877339 100644 --- a/lib/public/Preview/IVersionedPreviewFile.php +++ b/lib/public/Preview/IVersionedPreviewFile.php @@ -18,8 +18,7 @@ namespace OCP\Preview; */ interface IVersionedPreviewFile { /** - * @return numeric * @since 17.0.0 */ - public function getPreviewVersion(); + public function getPreviewVersion(): string; } diff --git a/tests/lib/Preview/BackgroundCleanupJobTest.php b/tests/lib/Preview/BackgroundCleanupJobTest.php index a2c72cbad57..80df690ad76 100644 --- a/tests/lib/Preview/BackgroundCleanupJobTest.php +++ b/tests/lib/Preview/BackgroundCleanupJobTest.php @@ -74,7 +74,7 @@ class BackgroundCleanupJobTest extends \Test\TestCase { $this->logout(); - foreach ($this->previewService->getAvailablePreviewForFile(5) as $preview) { + foreach ($this->previewService->getAvailablePreviewsForFile(5) as $preview) { $this->previewService->deletePreview($preview); } diff --git a/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index fc48ebc181a..ceaf483a658 100644 --- a/tests/lib/Preview/GeneratorTest.php +++ b/tests/lib/Preview/GeneratorTest.php @@ -14,7 +14,6 @@ use OC\Preview\GeneratorHelper; use OC\Preview\Storage\StorageFactory; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\File; -use OCP\Files\IMimeTypeLoader; use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\IConfig; @@ -22,10 +21,17 @@ use OCP\IImage; use OCP\IPreview; use OCP\Preview\BeforePreviewFetchedEvent; use OCP\Preview\IProviderV2; +use OCP\Preview\IVersionedPreviewFile; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; +abstract class VersionedPreviewFile implements IVersionedPreviewFile, File { + +} + class GeneratorTest extends TestCase { private IConfig&MockObject $config; private IPreview&MockObject $previewManager; @@ -35,7 +41,6 @@ class GeneratorTest extends TestCase { private LoggerInterface&MockObject $logger; private StorageFactory&MockObject $storageFactory; private PreviewMapper&MockObject $previewMapper; - private IMimeTypeLoader&MockObject $mimeTypeLoader; protected function setUp(): void { parent::setUp(); @@ -47,12 +52,6 @@ class GeneratorTest extends TestCase { $this->logger = $this->createMock(LoggerInterface::class); $this->previewMapper = $this->createMock(PreviewMapper::class); $this->storageFactory = $this->createMock(StorageFactory::class); - $this->mimeTypeLoader = $this->createMock(IMimeTypeLoader::class); - $this->mimeTypeLoader->method('getId') - ->willReturnCallback(fn ($mimeType) => $mimeType === 'image/png' ? 42 : 43); - $this->mimeTypeLoader->method('getMimetypeById') - ->with(42) - ->willReturn('image/png'); $this->generator = new Generator( $this->config, @@ -62,14 +61,18 @@ class GeneratorTest extends TestCase { $this->logger, $this->previewMapper, $this->storageFactory, - $this->mimeTypeLoader, ); } - private function getFile(int $fileId, string $mimeType): File { + private function getFile(int $fileId, string $mimeType, bool $hasVersion = false): File { $mountPoint = $this->createMock(IMountPoint::class); $mountPoint->method('getNumericStorageId')->willReturn(42); - $file = $this->createMock(File::class); + if ($hasVersion) { + $file = $this->createMock(VersionedPreviewFile::class); + $file->method('getPreviewVersion')->willReturn('abc'); + } else { + $file = $this->createMock(File::class); + } $file->method('isReadable') ->willReturn(true); $file->method('getMimeType') @@ -81,8 +84,10 @@ class GeneratorTest extends TestCase { return $file; } - public function testGetCachedPreview(): void { - $file = $this->getFile(42, 'myMimeType'); + #[TestWith([true])] + #[TestWith([false])] + public function testGetCachedPreview(bool $hasPreview): void { + $file = $this->getFile(42, 'myMimeType', $hasPreview); $this->previewManager->method('isMimeSupported') ->with($this->equalTo('myMimeType')) @@ -93,20 +98,20 @@ class GeneratorTest extends TestCase { $maxPreview->setHeight(1000); $maxPreview->setMax(true); $maxPreview->setSize(1000); - $maxPreview->setVersion(-1); $maxPreview->setCropped(false); $maxPreview->setStorageId(1); - $maxPreview->setMimetype($this->mimeTypeLoader->getId('image/png')); + $maxPreview->setVersion($hasPreview ? 'abc' : null); + $maxPreview->setMimeType('image/png'); $previewFile = new Preview(); $previewFile->setWidth(256); $previewFile->setHeight(256); $previewFile->setMax(false); $previewFile->setSize(1000); - $previewFile->setVersion(-1); + $previewFile->setVersion($hasPreview ? 'abc' : null); $previewFile->setCropped(false); $previewFile->setStorageId(1); - $previewFile->setMimetype($this->mimeTypeLoader->getId('image/png')); + $previewFile->setMimeType('image/png'); $this->previewMapper->method('getAvailablePreviews') ->with($this->equalTo([42])) @@ -120,12 +125,14 @@ class GeneratorTest extends TestCase { ->with(new BeforePreviewFetchedEvent($file, 100, 100, false, IPreview::MODE_FILL, null)); $result = $this->generator->getPreview($file, 100, 100); - $this->assertSame('256-256.png', $result->getName()); + $this->assertSame($hasPreview ? 'abc-256-256.png' : '256-256.png', $result->getName()); $this->assertSame(1000, $result->getSize()); } - public function testGetNewPreview(): void { - $file = $this->getFile(42, 'myMimeType'); + #[TestWith([true])] + #[TestWith([false])] + public function testGetNewPreview(bool $hasVersion): void { + $file = $this->getFile(42, 'myMimeType', $hasVersion); $this->previewManager->method('isMimeSupported') ->with($this->equalTo('myMimeType')) @@ -196,13 +203,6 @@ class GeneratorTest extends TestCase { $image->method('data') ->willReturn('my data'); - $maxPreview = new Preview(); - $maxPreview->setWidth(2048); - $maxPreview->setHeight(2048); - $maxPreview->setMax(true); - $maxPreview->setSize(1000); - $maxPreview->setMimetype($this->mimeTypeLoader->getId('image/png')); - $this->previewMapper->method('insert') ->willReturnCallback(fn (Preview $preview): Preview => $preview); @@ -210,16 +210,28 @@ class GeneratorTest extends TestCase { ->willReturnCallback(fn (Preview $preview): Preview => $preview); $this->storageFactory->method('writePreview') - ->willReturnCallback(function (Preview $preview, string $data): int { - switch ($preview->getName($this->mimeTypeLoader)) { - case '2048-2048-max.png': - $this->assertSame('my data', $data); - return 1000; - case '256-256.png': - $this->assertSame('my resized data', $data); - return 1000; + ->willReturnCallback(function (Preview $preview, mixed $data) use ($hasVersion): int { + $data = stream_get_contents($data); + if ($hasVersion) { + switch ($preview->getName()) { + case 'abc-2048-2048-max.png': + $this->assertSame('my data', $data); + return 1000; + case 'abc-256-256.png': + $this->assertSame('my resized data', $data); + return 1000; + } + } else { + switch ($preview->getName()) { + case '2048-2048-max.png': + $this->assertSame('my data', $data); + return 1000; + case '256-256.png': + $this->assertSame('my resized data', $data); + return 1000; + } } - $this->fail('file name is wrong:' . $preview->getName($this->mimeTypeLoader)); + $this->fail('file name is wrong:' . $preview->getName()); }); $image = $this->getMockImage(2048, 2048, 'my resized data'); @@ -231,7 +243,7 @@ class GeneratorTest extends TestCase { ->with(new BeforePreviewFetchedEvent($file, 100, 100, false, IPreview::MODE_FILL, null)); $result = $this->generator->getPreview($file, 100, 100); - $this->assertSame('256-256.png', $result->getName()); + $this->assertSame($hasVersion ? 'abc-256-256.png' : '256-256.png', $result->getName()); $this->assertSame(1000, $result->getSize()); } @@ -249,8 +261,8 @@ class GeneratorTest extends TestCase { $maxPreview->setHeight(2048); $maxPreview->setMax(true); $maxPreview->setSize(1000); - $maxPreview->setVersion(-1); - $maxPreview->setMimetype(42); + $maxPreview->setVersion(null); + $maxPreview->setMimetype('image/png'); $this->previewMapper->method('getAvailablePreviews') ->with($this->equalTo([42])) @@ -273,8 +285,8 @@ class GeneratorTest extends TestCase { $maxPreview->setHeight(2048); $maxPreview->setMax(true); $maxPreview->setSize(1000); - $maxPreview->setVersion(-1); - $maxPreview->setMimetype($this->mimeTypeLoader->getId('image/png')); + $maxPreview->setVersion(null); + $maxPreview->setMimeType('image/png'); $previewFile = new Preview(); $previewFile->setWidth(1024); @@ -282,8 +294,8 @@ class GeneratorTest extends TestCase { $previewFile->setMax(false); $previewFile->setSize(1000); $previewFile->setCropped(true); - $previewFile->setVersion(-1); - $previewFile->setMimetype($this->mimeTypeLoader->getId('image/png')); + $previewFile->setVersion(null); + $previewFile->setMimeType('image/png'); $this->previewMapper->method('getAvailablePreviews') ->with($this->equalTo([42])) @@ -321,7 +333,7 @@ class GeneratorTest extends TestCase { $this->generator->getPreview($file, 100, 100); } - private function getMockImage(int $width, int $height, $data = null) { + private function getMockImage(int $width, int $height, string $data = '') { $image = $this->createMock(IImage::class); $image->method('height')->willReturn($width); $image->method('width')->willReturn($height); @@ -378,7 +390,7 @@ class GeneratorTest extends TestCase { ]; } - #[\PHPUnit\Framework\Attributes\DataProvider('dataSize')] + #[DataProvider('dataSize')] public function testCorrectSize(int $maxX, int $maxY, int $reqX, int $reqY, bool $crop, string $mode, int $expectedX, int $expectedY): void { $file = $this->getFile(42, 'myMimeType'); @@ -391,11 +403,11 @@ class GeneratorTest extends TestCase { $maxPreview->setHeight($maxY); $maxPreview->setMax(true); $maxPreview->setSize(1000); - $maxPreview->setVersion(-1); - $maxPreview->setMimetype(42); + $maxPreview->setVersion(null); + $maxPreview->setMimeType('image/png'); - $this->assertSame($maxPreview->getName($this->mimeTypeLoader), $maxX . '-' . $maxY . '-max.png'); - $this->assertSame($maxPreview->getMimetypeValue($this->mimeTypeLoader), 'image/png'); + $this->assertSame($maxPreview->getName(), $maxX . '-' . $maxY . '-max.png'); + $this->assertSame($maxPreview->getMimeType(), 'image/png'); $this->previewMapper->method('getAvailablePreviews') ->with($this->equalTo([42])) @@ -415,7 +427,7 @@ class GeneratorTest extends TestCase { $this->previewMapper->method('insert') ->willReturnCallback(function (Preview $preview) use ($filename): Preview { - $this->assertSame($preview->getName($this->mimeTypeLoader), $filename); + $this->assertSame($preview->getName(), $filename); return $preview; }); @@ -431,7 +443,7 @@ class GeneratorTest extends TestCase { $result = $this->generator->getPreview($file, $reqX, $reqY, $crop, $mode); if ($expectedX === $maxX && $expectedY === $maxY) { - $this->assertSame($maxPreview->getName($this->mimeTypeLoader), $result->getName()); + $this->assertSame($maxPreview->getName(), $result->getName()); } else { $this->assertSame($filename, $result->getName()); } diff --git a/tests/lib/Preview/MovePreviewJobTest.php b/tests/lib/Preview/MovePreviewJobTest.php index a5ac5ad51e3..8c9df4274f0 100644 --- a/tests/lib/Preview/MovePreviewJobTest.php +++ b/tests/lib/Preview/MovePreviewJobTest.php @@ -21,6 +21,7 @@ use OCP\Files\IMimeTypeDetector; use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; use OCP\IAppConfig; +use OCP\IConfig; use OCP\IDBConnection; use OCP\Server; use PHPUnit\Framework\Attributes\TestDox; @@ -35,6 +36,7 @@ class MovePreviewJobTest extends TestCase { private IAppData $previewAppData; private PreviewMapper $previewMapper; private IAppConfig&MockObject $appConfig; + private IConfig $config; private StorageFactory $storageFactory; private PreviewService $previewService; private IDBConnection $db; @@ -46,6 +48,7 @@ class MovePreviewJobTest extends TestCase { parent::setUp(); $this->previewAppData = Server::get(IAppDataFactory::class)->get('preview'); $this->previewMapper = Server::get(PreviewMapper::class); + $this->config = Server::get(IConfig::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->appConfig->expects($this->any()) ->method('getValueBool') @@ -71,7 +74,7 @@ class MovePreviewJobTest extends TestCase { 'path_hash' => $qb->createNamedParameter(md5('test')), 'parent' => $qb->createNamedParameter(0), 'name' => $qb->createNamedParameter('abc'), - 'mimetype' => $qb->createNamedParameter(0), + 'mimetype' => $qb->createNamedParameter(42), 'size' => $qb->createNamedParameter(1000), 'mtime' => $qb->createNamedParameter(1000), 'storage_mtime' => $qb->createNamedParameter(1000), @@ -86,6 +89,7 @@ class MovePreviewJobTest extends TestCase { $this->mimeTypeDetector->method('detectPath')->willReturn('image/png'); $this->mimeTypeLoader = $this->createMock(IMimeTypeLoader::class); $this->mimeTypeLoader->method('getId')->with('image/png')->willReturn(42); + $this->mimeTypeLoader->method('getMimetypeById')->with(42)->willReturn('image/png'); $this->logger = $this->createMock(LoggerInterface::class); } @@ -108,11 +112,12 @@ class MovePreviewJobTest extends TestCase { $folder->newFile('128-128-crop.png', 'abcdefg'); $this->assertEquals(1, count($this->previewAppData->getDirectoryListing())); $this->assertEquals(2, count($folder->getDirectoryListing())); - $this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5)))); + $this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5)))); $job = new MovePreviewJob( Server::get(ITimeFactory::class), $this->appConfig, + $this->config, $this->previewMapper, $this->storageFactory, Server::get(IDBConnection::class), @@ -124,7 +129,7 @@ class MovePreviewJobTest extends TestCase { ); $this->invokePrivate($job, 'run', [[]]); $this->assertEquals(0, count($this->previewAppData->getDirectoryListing())); - $this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5)))); + $this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5)))); } private static function getInternalFolder(string $name): string { @@ -139,11 +144,12 @@ class MovePreviewJobTest extends TestCase { $folder = $this->previewAppData->getFolder(self::getInternalFolder((string)5)); $this->assertEquals(2, count($folder->getDirectoryListing())); - $this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5)))); + $this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5)))); $job = new MovePreviewJob( Server::get(ITimeFactory::class), $this->appConfig, + $this->config, $this->previewMapper, $this->storageFactory, Server::get(IDBConnection::class), @@ -155,7 +161,7 @@ class MovePreviewJobTest extends TestCase { ); $this->invokePrivate($job, 'run', [[]]); $this->assertEquals(0, count($this->previewAppData->getDirectoryListing())); - $this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5)))); + $this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5)))); } #[TestDox("Test the migration from the 'new' nested hierarchy to the database format")] @@ -178,11 +184,12 @@ class MovePreviewJobTest extends TestCase { $folder = $this->previewAppData->getFolder(self::getInternalFolder((string)5)); $this->assertEquals(9, count($folder->getDirectoryListing())); - $this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5)))); + $this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5)))); $job = new MovePreviewJob( Server::get(ITimeFactory::class), $this->appConfig, + $this->config, $this->previewMapper, $this->storageFactory, Server::get(IDBConnection::class), @@ -193,25 +200,25 @@ class MovePreviewJobTest extends TestCase { Server::get(IAppDataFactory::class) ); $this->invokePrivate($job, 'run', [[]]); - $this->assertEquals(0, count($this->previewAppData->getDirectoryListing())); - $previews = iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5)); + $previews = iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5)); $this->assertEquals(9, count($previews)); + $this->assertEquals(0, count($this->previewAppData->getDirectoryListing())); $nameVersionMapping = []; foreach ($previews as $preview) { - $nameVersionMapping[$preview->getName()] = $preview->getVersion(); + $nameVersionMapping[$preview->getName($this->mimeTypeLoader)] = $preview->getVersion(); } $this->assertEquals([ - '1000-128-128.png' => 1000, '1000-128-128-crop.png' => 1000, + '1000-128-128.png' => 1000, '1000-256-256-max.png' => 1000, - '1001-128-128.png' => 1001, '1001-128-128-crop.png' => 1001, + '1001-128-128.png' => 1001, '1001-256-256-max.png' => 1001, - '128-128.png' => -1, - '128-128-crop.png' => -1, - '256-256-max.png' => -1, + '128-128-crop.png' => null, + '128-128.png' => null, + '256-256-max.png' => null, ], $nameVersionMapping); } } diff --git a/tests/lib/Preview/PreviewMapperTest.php b/tests/lib/Preview/PreviewMapperTest.php index 19018c8b318..8e27a642473 100644 --- a/tests/lib/Preview/PreviewMapperTest.php +++ b/tests/lib/Preview/PreviewMapperTest.php @@ -12,9 +12,7 @@ namespace Test\Preview; use OC\Preview\Db\Preview; use OC\Preview\Db\PreviewMapper; -use OCP\Files\IMimeTypeLoader; use OCP\IDBConnection; -use OCP\IPreview; use OCP\Server; use Test\TestCase; @@ -24,12 +22,10 @@ use Test\TestCase; class PreviewMapperTest extends TestCase { private PreviewMapper $previewMapper; private IDBConnection $connection; - private IMimeTypeLoader $mimeTypeLoader; public function setUp(): void { $this->previewMapper = Server::get(PreviewMapper::class); $this->connection = Server::get(IDBConnection::class); - $this->mimeTypeLoader = Server::get(IMimeTypeLoader::class); } public function testGetAvailablePreviews(): void { @@ -71,11 +67,11 @@ class PreviewMapperTest extends TestCase { $preview->setCropped(true); $preview->setMax(true); $preview->setWidth(100); - $preview->setSourceMimetype(1); + $preview->setSourceMimeType('image/jpeg'); $preview->setHeight(100); $preview->setSize(100); $preview->setMtime(time()); - $preview->setMimetype($this->mimeTypeLoader->getId('image/jpeg')); + $preview->setMimetype('image/jpeg'); $preview->setEtag('abcdefg'); if ($locationId !== null) { diff --git a/tests/lib/Preview/PreviewServiceTest.php b/tests/lib/Preview/PreviewServiceTest.php index fe8dd1c3d33..f3f9c8ae895 100644 --- a/tests/lib/Preview/PreviewServiceTest.php +++ b/tests/lib/Preview/PreviewServiceTest.php @@ -13,7 +13,6 @@ namespace Test\Preview; use OC\Preview\Db\Preview; use OC\Preview\Db\PreviewMapper; use OC\Preview\PreviewService; -use OCP\IPreview; use OCP\Server; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -24,6 +23,7 @@ use PHPUnit\Framework\TestCase; #[CoversClass(PreviewService::class)] class PreviewServiceTest extends TestCase { private PreviewService $previewService; + private PreviewMapper $previewMapper; protected function setUp(): void { $this->previewService = Server::get(PreviewService::class); @@ -43,10 +43,10 @@ class PreviewServiceTest extends TestCase { $preview->setWidth($i); $preview->setHeight($i); $preview->setMax(true); - $preview->setSourceMimetype(1); + $preview->setSourceMimeType('image/jpeg'); $preview->setCropped(true); $preview->setEncrypted(false); - $preview->setMimetype(42); + $preview->setMimetype('image/jpeg'); $preview->setEtag('abc'); $preview->setMtime((new \DateTime())->getTimestamp()); $preview->setSize(0);