Merge pull request #54543 from nextcloud/preview-db-rework

This commit is contained in:
Benjamin Gaussorgues 2025-10-08 16:39:23 +02:00 committed by GitHub
commit 3aa0c23e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2318 additions and 1499 deletions

View file

@ -12,6 +12,7 @@ use OC\DB\Connection;
use OC\DB\ConnectionAdapter;
use OC\Files\Utils\Scanner;
use OC\ForbiddenException;
use OC\Preview\Storage\StorageFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
@ -32,10 +33,12 @@ class ScanAppData extends Base {
protected int $foldersCounter = 0;
protected int $filesCounter = 0;
protected int $previewsCounter = -1;
public function __construct(
protected IRootFolder $rootFolder,
protected IConfig $config,
private StorageFactory $previewStorage,
) {
parent::__construct();
}
@ -51,6 +54,14 @@ class ScanAppData extends Base {
}
protected function scanFiles(OutputInterface $output, string $folder): int {
if ($folder === 'preview' || $folder === '') {
$this->previewsCounter = $this->previewStorage->scan();
if ($folder === 'preview') {
return self::SUCCESS;
}
}
try {
/** @var Folder $appData */
$appData = $this->getAppDataFolder();
@ -134,7 +145,7 @@ class ScanAppData extends Base {
$this->initTools();
$exitCode = $this->scanFiles($output, $folder);
if ($exitCode === 0) {
if ($exitCode === self::SUCCESS) {
$this->presentStats($output);
}
return $exitCode;
@ -162,7 +173,7 @@ class ScanAppData extends Base {
*
* @throws \ErrorException
*/
public function exceptionErrorHandler($severity, $message, $file, $line) {
public function exceptionErrorHandler(int $severity, string $message, string $file, int $line): void {
if (!(error_reporting() & $severity)) {
// This error code is not included in error_reporting
return;
@ -173,10 +184,12 @@ class ScanAppData extends Base {
protected function presentStats(OutputInterface $output): void {
// Stop the timer
$this->execTime += microtime(true);
$headers = [
'Folders', 'Files', 'Elapsed time'
];
if ($this->previewsCounter !== -1) {
$headers[] = 'Previews';
}
$headers[] = 'Folders';
$headers[] = 'Files';
$headers[] = 'Elapsed time';
$this->showSummary($headers, null, $output);
}
@ -187,14 +200,15 @@ class ScanAppData extends Base {
* @param string[] $headers
* @param string[] $rows
*/
protected function showSummary($headers, $rows, OutputInterface $output): void {
protected function showSummary(array $headers, ?array $rows, OutputInterface $output): void {
$niceDate = $this->formatExecTime();
if (!$rows) {
$rows = [
$this->foldersCounter,
$this->filesCounter,
$niceDate,
];
if ($this->previewsCounter !== -1) {
$rows[] = $this->previewsCounter;
}
$rows[] = $this->foldersCounter;
$rows[] = $this->filesCounter;
$rows[] = $niceDate;
}
$table = new Table($output);
$table

View file

@ -1386,9 +1386,11 @@
<code><![CDATA[listen]]></code>
<code><![CDATA[listen]]></code>
</DeprecatedMethod>
<InvalidArgument>
<code><![CDATA[[$this, 'exceptionErrorHandler']]]></code>
</InvalidArgument>
<NullArgument>
<code><![CDATA[null]]></code>
<code><![CDATA[null]]></code>
</NullArgument>
</file>
<file src="apps/files/lib/Controller/DirectEditingController.php">
@ -3156,17 +3158,6 @@
<code><![CDATA[$this->timeFactory->getTime()]]></code>
</InvalidScalarArgument>
</file>
<file src="core/Command/Preview/Repair.php">
<UndefinedInterfaceMethod>
<code><![CDATA[section]]></code>
<code><![CDATA[section]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="core/Command/Preview/ResetRenderedTexts.php">
<InvalidReturnStatement>
<code><![CDATA[[]]]></code>
</InvalidReturnStatement>
</file>
<file src="core/Command/Security/BruteforceAttempts.php">
<DeprecatedMethod>
<code><![CDATA[getAttempts]]></code>
@ -4135,16 +4126,6 @@
<code><![CDATA[\OCA\Notifications\App]]></code>
</UndefinedClass>
</file>
<file src="lib/private/Preview/BackgroundCleanupJob.php">
<InvalidReturnStatement>
<code><![CDATA[[]]]></code>
</InvalidReturnStatement>
</file>
<file src="lib/private/Preview/Generator.php">
<LessSpecificReturnType>
<code><![CDATA[null|string]]></code>
</LessSpecificReturnType>
</file>
<file src="lib/private/Preview/ProviderV1Adapter.php">
<InvalidReturnStatement>
<code><![CDATA[$thumbnail === false ? null: $thumbnail]]></code>
@ -4410,14 +4391,6 @@
<code><![CDATA[array{X-Request-Id: string, Cache-Control: string, Content-Security-Policy: string, Feature-Policy: string, X-Robots-Tag: string, Last-Modified?: string, ETag?: string, ...H}]]></code>
</MoreSpecificReturnType>
</file>
<file src="lib/public/Preview/BeforePreviewFetchedEvent.php">
<LessSpecificReturnStatement>
<code><![CDATA[$this->mode]]></code>
</LessSpecificReturnStatement>
<MoreSpecificReturnType>
<code><![CDATA[null|IPreview::MODE_FILL|IPreview::MODE_COVER]]></code>
</MoreSpecificReturnType>
</file>
<file src="ocs-provider/index.php">
<DeprecatedMethod>
<code><![CDATA[getAppManager]]></code>

View file

@ -0,0 +1,9 @@
<?php
/*
* SPDX-FileCopyrightText: None
* SPDX-License-Identifier: CC0-1.0
*/
// PHP 8.4
function array_find(array $array, callable $callback) {}

View file

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\BackgroundJobs;
use OC\Files\SimpleFS\SimpleFile;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
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\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,
private readonly IRootFolder $rootFolder,
private readonly IMimeTypeDetector $mimeTypeDetector,
private readonly IMimeTypeLoader $mimeTypeLoader,
private readonly LoggerInterface $logger,
IAppDataFactory $appDataFactory,
) {
parent::__construct($time);
$this->appData = $appDataFactory->get('preview');
$this->setTimeSensitivity(self::TIME_INSENSITIVE);
$this->setInterval(24 * 60 * 60);
$this->previewRootPath = 'appdata_' . $this->config->getSystemValueString('instanceid') . '/preview/';
}
#[Override]
protected function run(mixed $argument): void {
if ($this->appConfig->getValueBool('core', 'previewMovedDone')) {
return;
}
$startTime = time();
while (true) {
$qb = $this->connection->getQueryBuilder();
$qb->select('path')
->from('filecache')
// 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();
$foundPreviews = $this->processQueryResult($result);
if (!$foundPreviews) {
break;
}
// Stop if execution time is more than one hour.
if (time() - $startTime > 3600) {
return;
}
}
$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<string|int, string[]> $previewFolders
*/
private function processPreviews(int $fileId, bool $flatPath): void {
$internalPath = $this->getInternalFolder((string)$fileId, $flatPath);
$folder = $this->appData->getFolder($internalPath);
/**
* @var list<array{file: SimpleFile, preview: Preview}> $previewFiles
*/
$previewFiles = [];
foreach ($folder->getDirectoryListing() as $previewFile) {
$path = $fileId . '/' . $previewFile->getName();
/** @var SimpleFile $previewFile */
$preview = Preview::fromPath($path, $this->mimeTypeDetector);
if (!$preview) {
$this->logger->error('Unable to import old preview at path.');
continue;
}
$preview->setSize($previewFile->getSize());
$preview->setMtime($previewFile->getMtime());
$preview->setOldFileId($previewFile->getId());
$preview->setEncrypted(false);
$previewFiles[] = [
'file' => $previewFile,
'preview' => $preview,
];
}
$qb = $this->connection->getQueryBuilder();
$qb->select('storage', 'etag', 'mimetype')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($fileId)))
->setMaxResults(1);
$result = $qb->executeQuery();
$result = $result->fetchAll();
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($this->mimeTypeLoader->getMimetypeById((int)$result[0]['mimetype']));
try {
$preview = $this->previewMapper->insert($preview);
} 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) {
$this->previewMapper->delete($preview);
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);
}
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): 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;
}
}
}
}

View file

@ -1,293 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\Preview;
use bantu\IniGetWrapper\IniGetWrapper;
use OC\Preview\Storage\Root;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use function pcntl_signal;
class Repair extends Command {
private bool $stopSignalReceived = false;
private int $memoryLimit;
private int $memoryTreshold;
public function __construct(
protected IConfig $config,
private IRootFolder $rootFolder,
private LoggerInterface $logger,
IniGetWrapper $phpIni,
private ILockingProvider $lockingProvider,
) {
$this->memoryLimit = (int)$phpIni->getBytes('memory_limit');
$this->memoryTreshold = $this->memoryLimit - 25 * 1024 * 1024;
parent::__construct();
}
protected function configure() {
$this
->setName('preview:repair')
->setDescription('distributes the existing previews into subfolders')
->addOption('batch', 'b', InputOption::VALUE_NONE, 'Batch mode - will not ask to start the migration and start it right away.')
->addOption('dry', 'd', InputOption::VALUE_NONE, 'Dry mode - will not create, move or delete any files - in combination with the verbose mode one could check the operations.')
->addOption('delete', null, InputOption::VALUE_NONE, 'Delete instead of migrating them. Usefull if too many entries to migrate.');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->memoryLimit !== -1) {
$limitInMiB = round($this->memoryLimit / 1024 / 1024, 1);
$thresholdInMiB = round($this->memoryTreshold / 1024 / 1024, 1);
$output->writeln("Memory limit is $limitInMiB MiB");
$output->writeln("Memory threshold is $thresholdInMiB MiB");
$output->writeln('');
$memoryCheckEnabled = true;
} else {
$output->writeln('No memory limit in place - disabled memory check. Set a PHP memory limit to automatically stop the execution of this migration script once memory consumption is close to this limit.');
$output->writeln('');
$memoryCheckEnabled = false;
}
$dryMode = $input->getOption('dry');
$deleteMode = $input->getOption('delete');
if ($dryMode) {
$output->writeln('INFO: The migration is run in dry mode and will not modify anything.');
$output->writeln('');
} elseif ($deleteMode) {
$output->writeln('WARN: The migration will _DELETE_ old previews.');
$output->writeln('');
}
$instanceId = $this->config->getSystemValueString('instanceid');
$output->writeln('This will migrate all previews from the old preview location to the new one.');
$output->writeln('');
$output->writeln('Fetching previews that need to be migrated …');
/** @var Folder $currentPreviewFolder */
$currentPreviewFolder = $this->rootFolder->get("appdata_$instanceId/preview");
$directoryListing = $currentPreviewFolder->getDirectoryListing();
$total = count($directoryListing);
/**
* by default there could be 0-9 a-f and the old-multibucket folder which are all fine
*/
if ($total < 18) {
$directoryListing = array_filter($directoryListing, function ($dir) {
if ($dir->getName() === 'old-multibucket') {
return false;
}
// a-f can't be a file ID -> removing from migration
if (preg_match('!^[a-f]$!', $dir->getName())) {
return false;
}
if (preg_match('!^[0-9]$!', $dir->getName())) {
// ignore folders that only has folders in them
if ($dir instanceof Folder) {
foreach ($dir->getDirectoryListing() as $entry) {
if (!$entry instanceof Folder) {
return true;
}
}
return false;
}
}
return true;
});
$total = count($directoryListing);
}
if ($total === 0) {
$output->writeln('All previews are already migrated.');
return 0;
}
$output->writeln("A total of $total preview files need to be migrated.");
$output->writeln('');
$output->writeln('The migration will always migrate all previews of a single file in a batch. After each batch the process can be canceled by pressing CTRL-C. This will finish the current batch and then stop the migration. This migration can then just be started and it will continue.');
if ($input->getOption('batch')) {
$output->writeln('Batch mode active: migration is started right away.');
} else {
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('<info>Should the migration be started? (y/[n]) </info>', false);
if (!$helper->ask($input, $output, $question)) {
return 0;
}
}
// register the SIGINT listener late in here to be able to exit in the early process of this command
pcntl_signal(SIGINT, [$this, 'sigIntHandler']);
$output->writeln('');
$output->writeln('');
$section1 = $output->section();
$section2 = $output->section();
$progressBar = new ProgressBar($section2, $total);
$progressBar->setFormat('%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% Used Memory: %memory:6s%');
$time = (new \DateTime())->format('H:i:s');
$progressBar->setMessage("$time Starting …");
$progressBar->maxSecondsBetweenRedraws(0.2);
$progressBar->start();
foreach ($directoryListing as $oldPreviewFolder) {
pcntl_signal_dispatch();
$name = $oldPreviewFolder->getName();
$time = (new \DateTime())->format('H:i:s');
$section1->writeln("$time Migrating previews of file with fileId $name");
$progressBar->display();
if ($this->stopSignalReceived) {
$section1->writeln("$time Stopping migration …");
return 0;
}
if (!$oldPreviewFolder instanceof Folder) {
$section1->writeln(" Skipping non-folder $name");
$progressBar->advance();
continue;
}
if ($name === 'old-multibucket') {
$section1->writeln(" Skipping fallback mount point $name");
$progressBar->advance();
continue;
}
if (in_array($name, ['a', 'b', 'c', 'd', 'e', 'f'])) {
$section1->writeln(" Skipping hex-digit folder $name");
$progressBar->advance();
continue;
}
if (!preg_match('!^\d+$!', $name)) {
$section1->writeln(" Skipping non-numeric folder $name");
$progressBar->advance();
continue;
}
$newFoldername = Root::getInternalFolder($name);
$memoryUsage = memory_get_usage();
if ($memoryCheckEnabled && $memoryUsage > $this->memoryTreshold) {
$section1->writeln('');
$section1->writeln('');
$section1->writeln('');
$section1->writeln(' Stopped process 25 MB before reaching the memory limit to avoid a hard crash.');
$time = (new \DateTime())->format('H:i:s');
$section1->writeln("$time Reached memory limit and stopped to avoid hard crash.");
return 1;
}
$lockName = 'occ preview:repair lock ' . $oldPreviewFolder->getId();
try {
$section1->writeln(" Locking \"$lockName\" ", OutputInterface::VERBOSITY_VERBOSE);
$this->lockingProvider->acquireLock($lockName, ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException $e) {
$section1->writeln(' Skipping because it is locked - another process seems to work on this …');
continue;
}
$previews = $oldPreviewFolder->getDirectoryListing();
if ($previews !== []) {
try {
$this->rootFolder->get("appdata_$instanceId/preview/$newFoldername");
} catch (NotFoundException $e) {
$section1->writeln(" Create folder preview/$newFoldername", OutputInterface::VERBOSITY_VERBOSE);
if (!$dryMode) {
$this->rootFolder->newFolder("appdata_$instanceId/preview/$newFoldername");
}
}
foreach ($previews as $preview) {
pcntl_signal_dispatch();
$previewName = $preview->getName();
if ($preview instanceof Folder) {
$section1->writeln(" Skipping folder $name/$previewName");
$progressBar->advance();
continue;
}
// Execute process
if (!$dryMode) {
// Delete preview instead of moving
if ($deleteMode) {
try {
$section1->writeln(" Delete preview/$name/$previewName", OutputInterface::VERBOSITY_VERBOSE);
$preview->delete();
} catch (\Exception $e) {
$this->logger->error("Failed to delete preview at preview/$name/$previewName", [
'app' => 'core',
'exception' => $e,
]);
}
} else {
try {
$section1->writeln(" Move preview/$name/$previewName to preview/$newFoldername", OutputInterface::VERBOSITY_VERBOSE);
$preview->move("appdata_$instanceId/preview/$newFoldername/$previewName");
} catch (\Exception $e) {
$this->logger->error("Failed to move preview from preview/$name/$previewName to preview/$newFoldername", [
'app' => 'core',
'exception' => $e,
]);
}
}
}
}
}
if ($oldPreviewFolder->getDirectoryListing() === []) {
$section1->writeln(" Delete empty folder preview/$name", OutputInterface::VERBOSITY_VERBOSE);
if (!$dryMode) {
try {
$oldPreviewFolder->delete();
} catch (\Exception $e) {
$this->logger->error("Failed to delete empty folder preview/$name", [
'app' => 'core',
'exception' => $e,
]);
}
}
}
$this->lockingProvider->releaseLock($lockName, ILockingProvider::LOCK_EXCLUSIVE);
$section1->writeln(' Unlocked', OutputInterface::VERBOSITY_VERBOSE);
$section1->writeln(" Finished migrating previews of file with fileId $name");
$progressBar->advance();
}
$progressBar->finish();
$output->writeln('');
return 0;
}
protected function sigIntHandler() {
echo "\nSignal received - will finish the step and then stop the migration.\n\n\n";
$this->stopSignalReceived = true;
}
}

View file

@ -8,14 +8,14 @@ declare(strict_types=1);
*/
namespace OC\Core\Command\Preview;
use OC\Preview\Storage\Root;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\IMimeTypeLoader;
use OC\Preview\Db\Preview;
use OC\Preview\PreviewService;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAvatarManager;
use OCP\IDBConnection;
use OCP\IUserManager;
use Override;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@ -23,22 +23,23 @@ use Symfony\Component\Console\Output\OutputInterface;
class ResetRenderedTexts extends Command {
public function __construct(
protected IDBConnection $connection,
protected IUserManager $userManager,
protected IAvatarManager $avatarManager,
private Root $previewFolder,
private IMimeTypeLoader $mimeTypeLoader,
protected readonly IDBConnection $connection,
protected readonly IUserManager $userManager,
protected readonly IAvatarManager $avatarManager,
private readonly PreviewService $previewService,
) {
parent::__construct();
}
protected function configure() {
#[Override]
protected function configure(): void {
$this
->setName('preview:reset-rendered-texts')
->setDescription('Deletes all generated avatars and previews of text and md files')
->addOption('dry', 'd', InputOption::VALUE_NONE, 'Dry mode - will not delete any files - in combination with the verbose mode one could check the operations.');
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$dryMode = $input->getOption('dry');
@ -67,9 +68,7 @@ class ResetRenderedTexts extends Command {
try {
$avatar->remove();
} catch (NotFoundException $e) {
// continue
} catch (NotPermittedException $e) {
} catch (NotFoundException|NotPermittedException) {
// continue
}
}
@ -91,8 +90,8 @@ class ResetRenderedTexts extends Command {
private function deletePreviews(OutputInterface $output, bool $dryMode): void {
$previewsToDeleteCount = 0;
foreach ($this->getPreviewsToDelete() as ['name' => $previewFileId, 'path' => $filePath]) {
$output->writeln('Deleting previews for ' . $filePath, OutputInterface::VERBOSITY_VERBOSE);
foreach ($this->getPreviewsToDelete() as $preview) {
$output->writeln('Deleting preview ' . $preview->getName() . ' for fileId ' . $preview->getFileId(), OutputInterface::VERBOSITY_VERBOSE);
$previewsToDeleteCount++;
@ -100,65 +99,20 @@ class ResetRenderedTexts extends Command {
continue;
}
try {
$preview = $this->previewFolder->getFolder((string)$previewFileId);
$preview->delete();
} catch (NotFoundException $e) {
// continue
} catch (NotPermittedException $e) {
// continue
}
$this->previewService->deletePreview($preview);
}
$output->writeln('Deleted ' . $previewsToDeleteCount . ' previews');
}
// Copy pasted and adjusted from
// "lib/private/Preview/BackgroundCleanupJob.php".
private function getPreviewsToDelete(): \Iterator {
$qb = $this->connection->getQueryBuilder();
$qb->select('path', 'mimetype')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($this->previewFolder->getId())));
$cursor = $qb->executeQuery();
$data = $cursor->fetch();
$cursor->closeCursor();
if ($data === null) {
return [];
}
/*
* This lovely like is the result of the way the new previews are stored
* We take the md5 of the name (fileid) and split the first 7 chars. That way
* there are not a gazillion files in the root of the preview appdata.
*/
$like = $this->connection->escapeLikeParameter($data['path']) . '/_/_/_/_/_/_/_/%';
$qb = $this->connection->getQueryBuilder();
$qb->select('a.name', 'b.path')
->from('filecache', 'a')
->leftJoin('a', 'filecache', 'b', $qb->expr()->eq(
$qb->expr()->castColumn('a.name', IQueryBuilder::PARAM_INT), 'b.fileid'
))
->where(
$qb->expr()->andX(
$qb->expr()->like('a.path', $qb->createNamedParameter($like)),
$qb->expr()->eq('a.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory'))),
$qb->expr()->orX(
$qb->expr()->eq('b.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/plain'))),
$qb->expr()->eq('b.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/markdown'))),
$qb->expr()->eq('b.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/x-markdown')))
)
)
);
$cursor = $qb->executeQuery();
while ($row = $cursor->fetch()) {
yield $row;
}
$cursor->closeCursor();
/**
* @return \Generator<Preview>
*/
private function getPreviewsToDelete(): \Generator {
return $this->previewService->getPreviewsForMimeTypes([
'text/plain',
'text/markdown',
'text/x-markdown'
]);
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Migrations;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\Attributes\CreateTable;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
*
*/
#[CreateTable(table: 'preview', description: 'Holds the preview data')]
#[CreateTable(table: 'preview_locations', description: 'Holds the preview location in an object store')]
class Version33000Date20250819110529 extends SimpleMigrationStep {
/**
* @param Closure(): ISchemaWrapper $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('preview_locations')) {
$table = $schema->createTable('preview_locations');
$table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('bucket_name', Types::STRING, ['notnull' => true, 'length' => 40]);
$table->addColumn('object_store_name', Types::STRING, ['notnull' => true, 'length' => 40]);
$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]);
$table->addColumn('file_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('storage_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('old_file_id', Types::BIGINT, ['notnull' => false, 'length' => 20, 'unsigned' => true]);
$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_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_id', Types::BIGINT, ['notnull' => true, 'default' => -1]);
$table->setPrimaryKey(['id']);
$table->addIndex(['file_id']);
$table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype_id', 'cropped', 'version_id'], 'previews_file_uniq_idx');
}
return $schema;
}
}

View file

@ -206,7 +206,6 @@ if ($config->getSystemValueBool('installed', false)) {
$application->add(Server::get(Command\Preview\Cleanup::class));
$application->add(Server::get(Generate::class));
$application->add(Server::get(Command\Preview\Repair::class));
$application->add(Server::get(ResetRenderedTexts::class));
$application->add(Server::get(Add::class));

View file

@ -1250,6 +1250,7 @@ return array(
'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => $baseDir . '/core/BackgroundJobs/CleanupLoginFlowV2.php',
'OC\\Core\\BackgroundJobs\\GenerateMetadataJob' => $baseDir . '/core/BackgroundJobs/GenerateMetadataJob.php',
'OC\\Core\\BackgroundJobs\\LookupServerSendCheckBackgroundJob' => $baseDir . '/core/BackgroundJobs/LookupServerSendCheckBackgroundJob.php',
'OC\\Core\\BackgroundJobs\\MovePreviewJob' => $baseDir . '/core/BackgroundJobs/MovePreviewJob.php',
'OC\\Core\\Command\\App\\Disable' => $baseDir . '/core/Command/App/Disable.php',
'OC\\Core\\Command\\App\\Enable' => $baseDir . '/core/Command/App/Enable.php',
'OC\\Core\\Command\\App\\GetPath' => $baseDir . '/core/Command/App/GetPath.php',
@ -1340,7 +1341,6 @@ return array(
'OC\\Core\\Command\\Memcache\\RedisCommand' => $baseDir . '/core/Command/Memcache/RedisCommand.php',
'OC\\Core\\Command\\Preview\\Cleanup' => $baseDir . '/core/Command/Preview/Cleanup.php',
'OC\\Core\\Command\\Preview\\Generate' => $baseDir . '/core/Command/Preview/Generate.php',
'OC\\Core\\Command\\Preview\\Repair' => $baseDir . '/core/Command/Preview/Repair.php',
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php',
'OC\\Core\\Command\\Router\\ListRoutes' => $baseDir . '/core/Command/Router/ListRoutes.php',
'OC\\Core\\Command\\Router\\MatchRoute' => $baseDir . '/core/Command/Router/MatchRoute.php',
@ -1525,6 +1525,7 @@ return array(
'OC\\Core\\Migrations\\Version32000Date20250620081925' => $baseDir . '/core/Migrations/Version32000Date20250620081925.php',
'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php',
'OC\\Core\\Migrations\\Version32000Date20250806110519' => $baseDir . '/core/Migrations/Version32000Date20250806110519.php',
'OC\\Core\\Migrations\\Version33000Date20250819110529' => $baseDir . '/core/Migrations/Version33000Date20250819110529.php',
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',
@ -1682,7 +1683,6 @@ return array(
'OC\\Files\\Mount\\MountPoint' => $baseDir . '/lib/private/Files/Mount/MountPoint.php',
'OC\\Files\\Mount\\MoveableMount' => $baseDir . '/lib/private/Files/Mount/MoveableMount.php',
'OC\\Files\\Mount\\ObjectHomeMountProvider' => $baseDir . '/lib/private/Files/Mount/ObjectHomeMountProvider.php',
'OC\\Files\\Mount\\ObjectStorePreviewCacheMountProvider' => $baseDir . '/lib/private/Files/Mount/ObjectStorePreviewCacheMountProvider.php',
'OC\\Files\\Mount\\RootMountProvider' => $baseDir . '/lib/private/Files/Mount/RootMountProvider.php',
'OC\\Files\\Node\\File' => $baseDir . '/lib/private/Files/Node/File.php',
'OC\\Files\\Node\\Folder' => $baseDir . '/lib/private/Files/Node/Folder.php',
@ -1877,6 +1877,8 @@ return array(
'OC\\Preview\\BackgroundCleanupJob' => $baseDir . '/lib/private/Preview/BackgroundCleanupJob.php',
'OC\\Preview\\Bitmap' => $baseDir . '/lib/private/Preview/Bitmap.php',
'OC\\Preview\\Bundled' => $baseDir . '/lib/private/Preview/Bundled.php',
'OC\\Preview\\Db\\Preview' => $baseDir . '/lib/private/Preview/Db/Preview.php',
'OC\\Preview\\Db\\PreviewMapper' => $baseDir . '/lib/private/Preview/Db/PreviewMapper.php',
'OC\\Preview\\EMF' => $baseDir . '/lib/private/Preview/EMF.php',
'OC\\Preview\\Font' => $baseDir . '/lib/private/Preview/Font.php',
'OC\\Preview\\GIF' => $baseDir . '/lib/private/Preview/GIF.php',
@ -1903,13 +1905,18 @@ return array(
'OC\\Preview\\PNG' => $baseDir . '/lib/private/Preview/PNG.php',
'OC\\Preview\\Photoshop' => $baseDir . '/lib/private/Preview/Photoshop.php',
'OC\\Preview\\Postscript' => $baseDir . '/lib/private/Preview/Postscript.php',
'OC\\Preview\\PreviewService' => $baseDir . '/lib/private/Preview/PreviewService.php',
'OC\\Preview\\Provider' => $baseDir . '/lib/private/Preview/Provider.php',
'OC\\Preview\\ProviderV1Adapter' => $baseDir . '/lib/private/Preview/ProviderV1Adapter.php',
'OC\\Preview\\ProviderV2' => $baseDir . '/lib/private/Preview/ProviderV2.php',
'OC\\Preview\\SGI' => $baseDir . '/lib/private/Preview/SGI.php',
'OC\\Preview\\SVG' => $baseDir . '/lib/private/Preview/SVG.php',
'OC\\Preview\\StarOffice' => $baseDir . '/lib/private/Preview/StarOffice.php',
'OC\\Preview\\Storage\\Root' => $baseDir . '/lib/private/Preview/Storage/Root.php',
'OC\\Preview\\Storage\\IPreviewStorage' => $baseDir . '/lib/private/Preview/Storage/IPreviewStorage.php',
'OC\\Preview\\Storage\\LocalPreviewStorage' => $baseDir . '/lib/private/Preview/Storage/LocalPreviewStorage.php',
'OC\\Preview\\Storage\\ObjectStorePreviewStorage' => $baseDir . '/lib/private/Preview/Storage/ObjectStorePreviewStorage.php',
'OC\\Preview\\Storage\\PreviewFile' => $baseDir . '/lib/private/Preview/Storage/PreviewFile.php',
'OC\\Preview\\Storage\\StorageFactory' => $baseDir . '/lib/private/Preview/Storage/StorageFactory.php',
'OC\\Preview\\TGA' => $baseDir . '/lib/private/Preview/TGA.php',
'OC\\Preview\\TIFF' => $baseDir . '/lib/private/Preview/TIFF.php',
'OC\\Preview\\TXT' => $baseDir . '/lib/private/Preview/TXT.php',
@ -1947,6 +1954,7 @@ return array(
'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => $baseDir . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php',
'OC\\Repair\\AddCleanupUpdaterBackupsJob' => $baseDir . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php',
'OC\\Repair\\AddMetadataGenerationJob' => $baseDir . '/lib/private/Repair/AddMetadataGenerationJob.php',
'OC\\Repair\\AddMovePreviewJob' => $baseDir . '/lib/private/Repair/AddMovePreviewJob.php',
'OC\\Repair\\AddRemoveOldTasksBackgroundJob' => $baseDir . '/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php',
'OC\\Repair\\CleanTags' => $baseDir . '/lib/private/Repair/CleanTags.php',
'OC\\Repair\\CleanUpAbandonedApps' => $baseDir . '/lib/private/Repair/CleanUpAbandonedApps.php',

View file

@ -1291,6 +1291,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupLoginFlowV2.php',
'OC\\Core\\BackgroundJobs\\GenerateMetadataJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/GenerateMetadataJob.php',
'OC\\Core\\BackgroundJobs\\LookupServerSendCheckBackgroundJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/LookupServerSendCheckBackgroundJob.php',
'OC\\Core\\BackgroundJobs\\MovePreviewJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/MovePreviewJob.php',
'OC\\Core\\Command\\App\\Disable' => __DIR__ . '/../../..' . '/core/Command/App/Disable.php',
'OC\\Core\\Command\\App\\Enable' => __DIR__ . '/../../..' . '/core/Command/App/Enable.php',
'OC\\Core\\Command\\App\\GetPath' => __DIR__ . '/../../..' . '/core/Command/App/GetPath.php',
@ -1381,7 +1382,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Command\\Memcache\\RedisCommand' => __DIR__ . '/../../..' . '/core/Command/Memcache/RedisCommand.php',
'OC\\Core\\Command\\Preview\\Cleanup' => __DIR__ . '/../../..' . '/core/Command/Preview/Cleanup.php',
'OC\\Core\\Command\\Preview\\Generate' => __DIR__ . '/../../..' . '/core/Command/Preview/Generate.php',
'OC\\Core\\Command\\Preview\\Repair' => __DIR__ . '/../../..' . '/core/Command/Preview/Repair.php',
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => __DIR__ . '/../../..' . '/core/Command/Preview/ResetRenderedTexts.php',
'OC\\Core\\Command\\Router\\ListRoutes' => __DIR__ . '/../../..' . '/core/Command/Router/ListRoutes.php',
'OC\\Core\\Command\\Router\\MatchRoute' => __DIR__ . '/../../..' . '/core/Command/Router/MatchRoute.php',
@ -1566,6 +1566,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Migrations\\Version32000Date20250620081925' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250620081925.php',
'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php',
'OC\\Core\\Migrations\\Version32000Date20250806110519' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250806110519.php',
'OC\\Core\\Migrations\\Version33000Date20250819110529' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20250819110529.php',
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',
@ -1723,7 +1724,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Files\\Mount\\MountPoint' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/MountPoint.php',
'OC\\Files\\Mount\\MoveableMount' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/MoveableMount.php',
'OC\\Files\\Mount\\ObjectHomeMountProvider' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/ObjectHomeMountProvider.php',
'OC\\Files\\Mount\\ObjectStorePreviewCacheMountProvider' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/ObjectStorePreviewCacheMountProvider.php',
'OC\\Files\\Mount\\RootMountProvider' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/RootMountProvider.php',
'OC\\Files\\Node\\File' => __DIR__ . '/../../..' . '/lib/private/Files/Node/File.php',
'OC\\Files\\Node\\Folder' => __DIR__ . '/../../..' . '/lib/private/Files/Node/Folder.php',
@ -1918,6 +1918,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Preview\\BackgroundCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Preview/BackgroundCleanupJob.php',
'OC\\Preview\\Bitmap' => __DIR__ . '/../../..' . '/lib/private/Preview/Bitmap.php',
'OC\\Preview\\Bundled' => __DIR__ . '/../../..' . '/lib/private/Preview/Bundled.php',
'OC\\Preview\\Db\\Preview' => __DIR__ . '/../../..' . '/lib/private/Preview/Db/Preview.php',
'OC\\Preview\\Db\\PreviewMapper' => __DIR__ . '/../../..' . '/lib/private/Preview/Db/PreviewMapper.php',
'OC\\Preview\\EMF' => __DIR__ . '/../../..' . '/lib/private/Preview/EMF.php',
'OC\\Preview\\Font' => __DIR__ . '/../../..' . '/lib/private/Preview/Font.php',
'OC\\Preview\\GIF' => __DIR__ . '/../../..' . '/lib/private/Preview/GIF.php',
@ -1944,13 +1946,18 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Preview\\PNG' => __DIR__ . '/../../..' . '/lib/private/Preview/PNG.php',
'OC\\Preview\\Photoshop' => __DIR__ . '/../../..' . '/lib/private/Preview/Photoshop.php',
'OC\\Preview\\Postscript' => __DIR__ . '/../../..' . '/lib/private/Preview/Postscript.php',
'OC\\Preview\\PreviewService' => __DIR__ . '/../../..' . '/lib/private/Preview/PreviewService.php',
'OC\\Preview\\Provider' => __DIR__ . '/../../..' . '/lib/private/Preview/Provider.php',
'OC\\Preview\\ProviderV1Adapter' => __DIR__ . '/../../..' . '/lib/private/Preview/ProviderV1Adapter.php',
'OC\\Preview\\ProviderV2' => __DIR__ . '/../../..' . '/lib/private/Preview/ProviderV2.php',
'OC\\Preview\\SGI' => __DIR__ . '/../../..' . '/lib/private/Preview/SGI.php',
'OC\\Preview\\SVG' => __DIR__ . '/../../..' . '/lib/private/Preview/SVG.php',
'OC\\Preview\\StarOffice' => __DIR__ . '/../../..' . '/lib/private/Preview/StarOffice.php',
'OC\\Preview\\Storage\\Root' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/Root.php',
'OC\\Preview\\Storage\\IPreviewStorage' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/IPreviewStorage.php',
'OC\\Preview\\Storage\\LocalPreviewStorage' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/LocalPreviewStorage.php',
'OC\\Preview\\Storage\\ObjectStorePreviewStorage' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/ObjectStorePreviewStorage.php',
'OC\\Preview\\Storage\\PreviewFile' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/PreviewFile.php',
'OC\\Preview\\Storage\\StorageFactory' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/StorageFactory.php',
'OC\\Preview\\TGA' => __DIR__ . '/../../..' . '/lib/private/Preview/TGA.php',
'OC\\Preview\\TIFF' => __DIR__ . '/../../..' . '/lib/private/Preview/TIFF.php',
'OC\\Preview\\TXT' => __DIR__ . '/../../..' . '/lib/private/Preview/TXT.php',
@ -1988,6 +1995,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php',
'OC\\Repair\\AddCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php',
'OC\\Repair\\AddMetadataGenerationJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddMetadataGenerationJob.php',
'OC\\Repair\\AddMovePreviewJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddMovePreviewJob.php',
'OC\\Repair\\AddRemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php',
'OC\\Repair\\CleanTags' => __DIR__ . '/../../..' . '/lib/private/Repair/CleanTags.php',
'OC\\Repair\\CleanUpAbandonedApps' => __DIR__ . '/../../..' . '/lib/private/Repair/CleanUpAbandonedApps.php',

View file

@ -8,7 +8,20 @@ declare(strict_types=1);
*/
namespace OC\Files\Cache;
use OCP\IConfig;
use OCP\Server;
use Override;
class LocalRootScanner extends Scanner {
private string $previewFolder;
public function __construct(\OC\Files\Storage\Storage $storage) {
parent::__construct($storage);
$config = Server::get(IConfig::class);
$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);
@ -17,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);
@ -25,6 +39,14 @@ class LocalRootScanner extends Scanner {
}
}
#[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 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');

View file

@ -11,14 +11,15 @@ use Doctrine\DBAL\Exception;
use OC\Files\Storage\Wrapper\Encryption;
use OC\Files\Storage\Wrapper\Jail;
use OC\Hooks\BasicEmitter;
use OC\SystemConfig;
use OCP\Files\Cache\IScanner;
use OCP\Files\ForbiddenException;
use OCP\Files\NotFoundException;
use OCP\Files\Storage\ILockingStorage;
use OCP\Files\Storage\IReliableEtagStorage;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Lock\ILockingProvider;
use OCP\Server;
use Psr\Log\LoggerInterface;
/**
@ -69,12 +70,11 @@ class Scanner extends BasicEmitter implements IScanner {
$this->storage = $storage;
$this->storageId = $this->storage->getId();
$this->cache = $storage->getCache();
/** @var SystemConfig $config */
$config = \OC::$server->get(SystemConfig::class);
$this->cacheActive = !$config->getValue('filesystem_cache_readonly', false);
$this->useTransactions = !$config->getValue('filescanner_no_transactions', false);
$this->lockingProvider = \OC::$server->get(ILockingProvider::class);
$this->connection = \OC::$server->get(IDBConnection::class);
$config = Server::get(IConfig::class);
$this->cacheActive = !$config->getSystemValueBool('filesystem_cache_readonly', false);
$this->useTransactions = !$config->getSystemValueBool('filescanner_no_transactions', false);
$this->lockingProvider = Server::get(ILockingProvider::class);
$this->connection = Server::get(IDBConnection::class);
}
/**
@ -318,7 +318,6 @@ class Scanner extends BasicEmitter implements IScanner {
try {
$data = $this->scanFile($path, $reuse, -1, lock: $lock);
if ($data !== null && $data['mimetype'] === 'httpd/unix-directory') {
$size = $this->scanChildren($path, $recursive, $reuse, $data['fileid'], $lock, $data['size']);
$data['size'] = $size;

View file

@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Files\Mount;
use OC\Files\ObjectStore\AppdataPreviewObjectStoreStorage;
use OC\Files\ObjectStore\ObjectStoreStorage;
use OC\Files\Storage\Wrapper\Jail;
use OCP\Files\Config\IRootMountProvider;
use OCP\Files\Storage\IStorageFactory;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
/**
* Mount provider for object store app data folder for previews
*/
class ObjectStorePreviewCacheMountProvider implements IRootMountProvider {
private LoggerInterface $logger;
/** @var IConfig */
private $config;
public function __construct(LoggerInterface $logger, IConfig $config) {
$this->logger = $logger;
$this->config = $config;
}
/**
* @return MountPoint[]
* @throws \Exception
*/
public function getRootMounts(IStorageFactory $loader): array {
if (!is_array($this->config->getSystemValue('objectstore_multibucket'))) {
return [];
}
if ($this->config->getSystemValue('objectstore.multibucket.preview-distribution', false) !== true) {
return [];
}
$instanceId = $this->config->getSystemValueString('instanceid', '');
$mountPoints = [];
$directoryRange = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
$i = 0;
foreach ($directoryRange as $parent) {
foreach ($directoryRange as $child) {
$mountPoints[] = new MountPoint(
AppdataPreviewObjectStoreStorage::class,
'/appdata_' . $instanceId . '/preview/' . $parent . '/' . $child,
$this->getMultiBucketObjectStore($i),
$loader,
null,
null,
self::class
);
$i++;
}
}
$rootStorageArguments = $this->getMultiBucketObjectStoreForRoot();
$fakeRootStorage = new ObjectStoreStorage($rootStorageArguments);
$fakeRootStorageJail = new Jail([
'storage' => $fakeRootStorage,
'root' => '/appdata_' . $instanceId . '/preview',
]);
// add a fallback location to be able to fetch existing previews from the old bucket
$mountPoints[] = new MountPoint(
$fakeRootStorageJail,
'/appdata_' . $instanceId . '/preview/old-multibucket',
null,
$loader,
null,
null,
self::class
);
return $mountPoints;
}
protected function getMultiBucketObjectStore(int $number): array {
$config = $this->config->getSystemValue('objectstore_multibucket');
// sanity checks
if (empty($config['class'])) {
$this->logger->error('No class given for objectstore', ['app' => 'files']);
}
if (!isset($config['arguments'])) {
$config['arguments'] = [];
}
/*
* Use any provided bucket argument as prefix
* and add the mapping from parent/child => bucket
*/
if (!isset($config['arguments']['bucket'])) {
$config['arguments']['bucket'] = '';
}
$config['arguments']['bucket'] .= "-preview-$number";
// instantiate object store implementation
$config['arguments']['objectstore'] = new $config['class']($config['arguments']);
$config['arguments']['internal-id'] = $number;
return $config['arguments'];
}
protected function getMultiBucketObjectStoreForRoot(): array {
$config = $this->config->getSystemValue('objectstore_multibucket');
// sanity checks
if (empty($config['class'])) {
$this->logger->error('No class given for objectstore', ['app' => 'files']);
}
if (!isset($config['arguments'])) {
$config['arguments'] = [];
}
/*
* Use any provided bucket argument as prefix
* and add the mapping from parent/child => bucket
*/
if (!isset($config['arguments']['bucket'])) {
$config['arguments']['bucket'] = '';
}
$config['arguments']['bucket'] .= '0';
// instantiate object store implementation
$config['arguments']['objectstore'] = new $config['class']($config['arguments']);
return $config['arguments'];
}
}

View file

@ -14,7 +14,7 @@ use OCP\IConfig;
use OCP\IUser;
/**
* @psalm-type ObjectStoreConfig array{class: class-string<IObjectStore>, arguments: array{multibucket: bool, ...}}
* @psalm-type ObjectStoreConfig array{class: class-string<IObjectStore>, arguments: array{multibucket: bool, objectPrefix?: string, ...}}
*/
class PrimaryObjectStoreConfig {
public function __construct(
@ -119,12 +119,14 @@ class PrimaryObjectStoreConfig {
'default' => 'server1',
'server1' => $this->validateObjectStoreConfig($objectStoreMultiBucket),
'root' => 'server1',
'preview' => 'server1',
];
} elseif ($objectStore) {
if (!isset($objectStore['default'])) {
$objectStore = [
'default' => 'server1',
'root' => 'server1',
'preview' => 'server1',
'server1' => $objectStore,
];
}
@ -132,6 +134,10 @@ class PrimaryObjectStoreConfig {
$objectStore['root'] = 'default';
}
if (!isset($objectStore['preview'])) {
$objectStore['preview'] = 'default';
}
if (!is_string($objectStore['default'])) {
throw new InvalidObjectStoreConfigurationException('The \'default\' object storage configuration is required to be a reference to another configuration.');
}
@ -155,7 +161,7 @@ class PrimaryObjectStoreConfig {
}
/**
* @param array|string $config
* @param array{multibucket?: bool, objectPrefix?: string, ...}|string $config
* @return string|ObjectStoreConfig
*/
private function validateObjectStoreConfig(array|string $config): array|string {

View file

@ -8,23 +8,23 @@ declare(strict_types=1);
*/
namespace OC\Preview;
use OC\Preview\Storage\Root;
use OC\Preview\Db\Preview;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IDBConnection;
/**
* @psalm-type FileId int
* @psalm-type StorageId int
*/
class BackgroundCleanupJob extends TimedJob {
public function __construct(
ITimeFactory $timeFactory,
private IDBConnection $connection,
private Root $previewFolder,
private IMimeTypeLoader $mimeTypeLoader,
private bool $isCLI,
readonly private IDBConnection $connection,
readonly private PreviewService $previewService,
readonly private bool $isCLI,
) {
parent::__construct($timeFactory);
// Run at most once an hour
@ -32,88 +32,29 @@ class BackgroundCleanupJob extends TimedJob {
$this->setTimeSensitivity(self::TIME_INSENSITIVE);
}
public function run($argument) {
public function run($argument): void {
foreach ($this->getDeletedFiles() as $fileId) {
try {
$preview = $this->previewFolder->getFolder((string)$fileId);
$preview->delete();
} catch (NotFoundException $e) {
// continue
} catch (NotPermittedException $e) {
// continue
$previewIds = [];
foreach ($this->previewService->getAvailablePreviewsForFile($fileId) as $preview) {
$this->previewService->deletePreview($preview);
}
}
}
/**
* @return \Iterator<FileId>
*/
private function getDeletedFiles(): \Iterator {
yield from $this->getOldPreviewLocations();
yield from $this->getNewPreviewLocations();
}
private function getOldPreviewLocations(): \Iterator {
if ($this->connection->getShardDefinition('filecache')) {
// sharding is new enough that we don't need to support this
return;
}
$qb = $this->connection->getQueryBuilder();
$qb->select('a.name')
->from('filecache', 'a')
->leftJoin('a', 'filecache', 'b', $qb->expr()->eq(
$qb->expr()->castColumn('a.name', IQueryBuilder::PARAM_INT), 'b.fileid'
))
->where(
$qb->expr()->andX(
$qb->expr()->isNull('b.fileid'),
$qb->expr()->eq('a.storage', $qb->createNamedParameter($this->previewFolder->getStorageId())),
$qb->expr()->eq('a.parent', $qb->createNamedParameter($this->previewFolder->getId())),
$qb->expr()->like('a.name', $qb->createNamedParameter('__%')),
$qb->expr()->eq('a.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory')))
)
);
if (!$this->isCLI) {
$qb->setMaxResults(10);
}
$cursor = $qb->executeQuery();
while ($row = $cursor->fetch()) {
yield $row['name'];
}
$cursor->closeCursor();
}
private function getNewPreviewLocations(): \Iterator {
$qb = $this->connection->getQueryBuilder();
$qb->select('path', 'mimetype')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($this->previewFolder->getId())));
$cursor = $qb->executeQuery();
$data = $cursor->fetch();
$cursor->closeCursor();
if ($data === null) {
return [];
}
if ($this->connection->getShardDefinition('filecache')) {
$chunks = $this->getAllPreviewIds($data['path'], 1000);
foreach ($chunks as $chunk) {
yield from $this->findMissingSources($chunk);
foreach ($this->previewService->getAvailableFileIds() as $availableFileIdGroup) {
$fileIds = $this->findMissingSources($availableFileIdGroup['storageId'], $availableFileIdGroup['fileIds']);
foreach ($fileIds as $fileId) {
yield $fileId;
}
}
return;
}
/*
* This lovely like is the result of the way the new previews are stored
* We take the md5 of the name (fileid) and split the first 7 chars. That way
* there are not a gazillion files in the root of the preview appdata.
*/
$like = $this->connection->escapeLikeParameter($data['path']) . '/_/_/_/_/_/_/_/%';
/*
* Deleting a file will not delete related previews right away.
*
@ -130,71 +71,36 @@ class BackgroundCleanupJob extends TimedJob {
* If the related file is deleted, b.fileid will be null and the preview folder can be deleted.
*/
$qb = $this->connection->getQueryBuilder();
$qb->select('a.name')
->from('filecache', 'a')
->leftJoin('a', 'filecache', 'b', $qb->expr()->eq(
$qb->expr()->castColumn('a.name', IQueryBuilder::PARAM_INT), 'b.fileid'
$qb->select('p.file_id')
->from('previews', 'p')
->leftJoin('p', 'filecache', 'f', $qb->expr()->eq(
'p.file_id', 'f.fileid'
))
->where(
$qb->expr()->andX(
$qb->expr()->eq('a.storage', $qb->createNamedParameter($this->previewFolder->getStorageId())),
$qb->expr()->isNull('b.fileid'),
$qb->expr()->like('a.path', $qb->createNamedParameter($like)),
$qb->expr()->eq('a.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory')))
)
);
->where($qb->expr()->isNull('f.fileid'));
if (!$this->isCLI) {
$qb->setMaxResults(10);
}
$cursor = $qb->executeQuery();
while ($row = $cursor->fetch()) {
yield $row['name'];
yield (int)$row['file_id'];
}
$cursor->closeCursor();
}
private function getAllPreviewIds(string $previewRoot, int $chunkSize): \Iterator {
// See `getNewPreviewLocations` for some more info about the logic here
$like = $this->connection->escapeLikeParameter($previewRoot) . '/_/_/_/_/_/_/_/%';
$qb = $this->connection->getQueryBuilder();
$qb->select('name', 'fileid')
->from('filecache')
->where(
$qb->expr()->andX(
$qb->expr()->eq('storage', $qb->createNamedParameter($this->previewFolder->getStorageId())),
$qb->expr()->like('path', $qb->createNamedParameter($like)),
$qb->expr()->eq('mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory'))),
$qb->expr()->gt('fileid', $qb->createParameter('min_id')),
)
)
->orderBy('fileid', 'ASC')
->setMaxResults($chunkSize);
$minId = 0;
while (true) {
$qb->setParameter('min_id', $minId);
$rows = $qb->executeQuery()->fetchAll();
if (count($rows) > 0) {
$minId = $rows[count($rows) - 1]['fileid'];
yield array_map(function ($row) {
return (int)$row['name'];
}, $rows);
} else {
break;
}
}
}
private function findMissingSources(array $ids): array {
/**
* @param FileId[] $ids
* @return FileId[]
*/
private function findMissingSources(int $storage, array $ids): array {
$qb = $this->connection->getQueryBuilder();
$qb->select('fileid')
->from('filecache')
->where($qb->expr()->in('fileid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
->where($qb->expr()->andX(
$qb->expr()->in('fileid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)),
$qb->expr()->eq('storage', $qb->createNamedParameter($storage, IQueryBuilder::PARAM_INT)),
));
$found = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
return array_diff($ids, $found);
}

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Db;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
use OCP\Files\IMimeTypeDetector;
/**
* Preview entity mapped to the oc_previews and oc_preview_locations table.
*
* @method int getFileId() Get the file id of the original file.
* @method void setFileId(int $fileId)
* @method int getStorageId() Get the storage id of the original file.
* @method void setStorageId(int $fileId)
* @method int getOldFileId() Get the old location in the file-cache table, for legacy compatibility.
* @method void setOldFileId(int $oldFileId)
* @method int getLocationId() Get the location id in the preview_locations table. Only set when using an object store as primary storage.
* @method void setLocationId(int $locationId)
* @method string getBucketName() Get the bucket name where the preview is stored. This is stored in the preview_locations table.
* @method string getObjectStoreName() Get the object store name where the preview is stored. This is stored in the preview_locations table.
* @method int getWidth() Get the width of the preview.
* @method void setWidth(int $width)
* @method int getHeight() Get the height of the preview.
* @method void setHeight(int $height)
* @method bool isCropped() Get whether the preview is cropped or not.
* @method void setCropped(bool $cropped)
* @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.
* @method void setSize(int $size)
* @method bool isMax() Get whether the preview is the biggest one which is then used to generate the smaller previews.
* @method void setMax(bool $max)
* @method string getEtag() Get the etag of the preview.
* @method void setEtag(string $etag)
* @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)
*
* @see PreviewMapper
*/
class Preview extends Entity {
protected ?int $fileId = null;
protected ?int $oldFileId = null;
protected ?int $storageId = null;
protected ?int $locationId = null;
protected ?string $bucketName = null;
protected ?string $objectStoreName = null;
protected ?int $width = null;
protected ?int $height = 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 ?string $version = null;
protected ?int $versionId = null;
protected ?bool $encrypted = null;
public function __construct() {
$this->addType('fileId', Types::BIGINT);
$this->addType('storageId', Types::BIGINT);
$this->addType('oldFileId', Types::BIGINT);
$this->addType('locationId', Types::BIGINT);
$this->addType('width', Types::INTEGER);
$this->addType('height', 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('versionId', Types::STRING);
}
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('/(([A-Za-z0-9\+\/]+)-)?([0-9]+)-([0-9]+)(-(max))?(-(crop))?\.([a-z]{3,4})/', $fileName, $matches);
if ($ok !== 1) {
return false;
}
[
2 => $version,
3 => $width,
4 => $height,
6 => $max,
8 => $crop,
] = $matches;
$preview->setMimeType($mimeTypeDetector->detectPath($fileName));
$preview->setWidth((int)$width);
$preview->setHeight((int)$height);
$preview->setCropped($crop === 'crop');
$preview->setMax($max === 'max');
if (!empty($version)) {
$preview->setVersion($version);
}
return $preview;
}
public function getName(): string {
$path = ($this->getVersion() > -1 ? $this->getVersion() . '-' : '') . $this->getWidth() . '-' . $this->getHeight();
if ($this->isCropped()) {
$path .= '-crop';
}
if ($this->isMax()) {
$path .= '-max';
}
$ext = $this->getExtension();
$path .= '.' . $ext;
return $path;
}
public function getExtension(): string {
return match ($this->getMimeType()) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
default => 'png',
};
}
public function setBucketName(string $bucketName): void {
$this->bucketName = $bucketName;
}
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;
}
}

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Db;
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 Override;
/**
* @template-extends QBMapper<Preview>
*/
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<Preview>
* @throws Exception
*/
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)));
yield from $this->yieldEntities($selectQb);
}
/**
* @param int[] $fileIds
* @return array<int, Preview[]>
* @throws Exception
*/
public function getAvailablePreviews(array $fileIds): array {
$selectQb = $this->db->getQueryBuilder();
$this->joinLocation($selectQb)
->where(
$selectQb->expr()->in('p.file_id', $selectQb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)),
);
$previews = array_fill_keys($fileIds, []);
foreach ($this->yieldEntities($selectQb) as $preview) {
$previews[$preview->getFileId()][] = $preview;
}
return $previews;
}
/**
* @return \Generator<Preview>
*/
public function getByFileId(int $fileId): \Generator {
$selectQb = $this->db->getQueryBuilder();
$this->joinLocation($selectQb)
->where($selectQb->expr()->eq('file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
yield from $this->yieldEntities($selectQb);
}
/**
* @param int[] $previewIds
*/
public function deleteByIds(array $previewIds): void {
$qb = $this->db->getQueryBuilder();
$qb->delete(self::TABLE_NAME)
->where($qb->expr()->andX(
$qb->expr()->in('id', $qb->createNamedParameter($previewIds, IQueryBuilder::PARAM_INT_ARRAY))
))->executeStatement();
}
protected function joinLocation(IQueryBuilder $qb): IQueryBuilder {
return $qb->select('p.*', 'l.bucket_name', 'l.object_store_name', 'v.version')
->from(self::TABLE_NAME, 'p')
->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'
));
}
public function getLocationId(string $bucket, string $objectStore): int {
$qb = $this->db->getQueryBuilder();
$result = $qb->select('id')
->from(self::LOCATION_TABLE_NAME)
->where($qb->expr()->eq('bucket_name', $qb->createNamedParameter($bucket)))
->andWhere($qb->expr()->eq('object_store_name', $qb->createNamedParameter($objectStore)))
->executeQuery();
$data = $result->fetchOne();
if ($data) {
return $data;
} else {
$qb->insert(self::LOCATION_TABLE_NAME)
->values([
'bucket_name' => $qb->createNamedParameter($bucket),
'object_store_name' => $qb->createNamedParameter($objectStore),
])->executeStatement();
return $qb->getLastInsertId();
}
}
public function deleteAll(): void {
$delete = $this->db->getQueryBuilder();
$delete->delete($this->getTableName());
}
/**
* @return \Generator<Preview>
*/
public function getPreviews(int $lastId, int $limit = 1000): \Generator {
$qb = $this->db->getQueryBuilder();
$this->joinLocation($qb)
->where($qb->expr()->gt('p.id', $qb->createNamedParameter($lastId, IQueryBuilder::PARAM_INT)))
->setMaxResults($limit);
return $this->yieldEntities($qb);
}
/**
* @param string[] $mimeTypes
* @return \Generator<Preview>
*/
public function getPreviewsForMimeTypes(array $mimeTypes): \Generator {
$qb = $this->db->getQueryBuilder();
$this->joinLocation($qb)
->where($qb->expr()->orX(
...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);
}
}

View file

@ -6,15 +6,17 @@
*/
namespace OC\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\PreviewFile;
use OC\Preview\Storage\StorageFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\InMemoryFile;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IConfig;
use OCP\IImage;
use OCP\IPreview;
@ -31,10 +33,11 @@ class Generator {
public function __construct(
private IConfig $config,
private IPreview $previewManager,
private IAppData $appData,
private GeneratorHelper $helper,
private IEventDispatcher $eventDispatcher,
private LoggerInterface $logger,
private PreviewMapper $previewMapper,
private StorageFactory $storageFactory,
) {
}
@ -104,32 +107,31 @@ class Generator {
$mimeType = $file->getMimeType();
}
$previewFolder = $this->getPreviewFolder($file);
// List every existing preview first instead of trying to find them one by one
$previewFiles = $previewFolder->getDirectoryListing();
[$file->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$file->getId()]);
$previewVersion = '';
$previewVersion = null;
if ($file instanceof IVersionedPreviewFile) {
$previewVersion = $file->getPreviewVersion() . '-';
$previewVersion = $file->getPreviewVersion();
}
// Get the max preview and infer the max preview sizes from that
$maxPreview = $this->getMaxPreview($previewFolder, $previewFiles, $file, $mimeType, $previewVersion);
$maxPreview = $this->getMaxPreview($previews, $file, $mimeType, $previewVersion);
$maxPreviewImage = null; // only load the image when we need it
if ($maxPreview->getSize() === 0) {
$maxPreview->delete();
$this->storageFactory->deletePreview($maxPreview);
$this->previewMapper->delete($maxPreview);
$this->logger->error('Max preview generated for file {path} has size 0, deleting and throwing exception.', ['path' => $file->getPath()]);
throw new NotFoundException('Max preview size 0, invalid!');
}
[$maxWidth, $maxHeight] = $this->getPreviewSize($maxPreview, $previewVersion);
$maxWidth = $maxPreview->getWidth();
$maxHeight = $maxPreview->getHeight();
if ($maxWidth <= 0 || $maxHeight <= 0) {
throw new NotFoundException('The maximum preview sizes are zero or less pixels');
}
$preview = null;
$previewFile = null;
foreach ($specifications as $specification) {
$width = $specification['width'] ?? -1;
$height = $specification['height'] ?? -1;
@ -148,38 +150,40 @@ 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
$preview = $maxPreview;
$previewFile = new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper);
continue;
}
// Try to get a cached preview. Else generate (and store) one
try {
try {
$preview = $this->getCachedPreview($previewFiles, $width, $height, $crop, $maxPreview->getMimeType(), $previewVersion);
} catch (NotFoundException $e) {
$preview = array_find($previews, fn (Preview $preview): bool => $preview->getWidth() === $width
&& $preview->getHeight() === $height && $preview->getMimetype() === $maxPreview->getMimetype()
&& $preview->getVersion() === $previewVersion && $preview->isCropped() === $crop);
if ($preview) {
$previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper);
} else {
if (!$this->previewManager->isMimeSupported($mimeType)) {
throw new NotFoundException();
}
if ($maxPreviewImage === null) {
$maxPreviewImage = $this->helper->getImage($maxPreview);
$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()]);
$preview = $this->generatePreview($previewFolder, $maxPreviewImage, $width, $height, $crop, $maxWidth, $maxHeight, $previewVersion, $cacheResult);
// New file, augment our array
$previewFiles[] = $preview;
$previewFile = $this->generatePreview($file, $maxPreviewImage, $width, $height, $crop, $maxWidth, $maxHeight, $previewVersion, $cacheResult);
}
} catch (\InvalidArgumentException $e) {
throw new NotFoundException('', 0, $e);
}
if ($preview->getSize() === 0) {
$preview->delete();
if ($previewFile->getSize() === 0) {
$previewFile->delete();
throw new NotFoundException('Cached preview size 0, invalid!');
}
}
assert($preview !== null);
assert($previewFile !== null);
// Free memory being used by the embedded image resource. Without this the image is kept in memory indefinitely.
// Garbage Collection does NOT free this memory. We have to do it ourselves.
@ -187,7 +191,7 @@ class Generator {
$maxPreviewImage->destroy();
}
return $preview;
return $previewFile;
}
/**
@ -289,31 +293,25 @@ class Generator {
}
/**
* @param ISimpleFolder $previewFolder
* @param ISimpleFile[] $previewFiles
* @param File $file
* @param string $mimeType
* @param string $prefix
* @return ISimpleFile
* @param Preview[] $previews
* @throws NotFoundException
*/
private function getMaxPreview(ISimpleFolder $previewFolder, array $previewFiles, File $file, $mimeType, $prefix) {
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 ($previewFiles as $node) {
$name = $node->getName();
if (($prefix === '' || str_starts_with($name, $prefix)) && strpos($name, 'max')) {
return $node;
foreach ($previews as $preview) {
if ($preview->isMax() && ($version === $preview->getVersion())) {
return $preview;
}
}
$maxWidth = $this->config->getSystemValueInt('preview_max_x', 4096);
$maxHeight = $this->config->getSystemValueInt('preview_max_y', 4096);
return $this->generateProviderPreview($previewFolder, $file, $maxWidth, $maxHeight, false, true, $mimeType, $prefix);
return $this->generateProviderPreview($file, $maxWidth, $maxHeight, false, true, $mimeType, $version);
}
private function generateProviderPreview(ISimpleFolder $previewFolder, File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, string $prefix) {
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
@ -322,6 +320,7 @@ class Generator {
}
foreach ($providers as $providerClosure) {
$provider = $this->helper->getProvider($providerClosure);
if (!($provider instanceof IProviderV2)) {
continue;
@ -348,18 +347,25 @@ class Generator {
continue;
}
$path = $this->generatePath($preview->width(), $preview->height(), $crop, $max, $preview->dataMimeType(), $prefix);
try {
if ($preview instanceof IStreamImage) {
return $previewFolder->newFile($path, $preview->resource());
} else {
return $previewFolder->newFile($path, $preview->data());
}
} catch (NotPermittedException $e) {
$previewEntry = new Preview();
$previewEntry->setFileId($file->getId());
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
$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($preview->dataMimeType());
$previewEntry->setEtag($file->getEtag());
$previewEntry->setMtime((new \DateTime())->getTimestamp());
$previewEntry->setSize(0);
return $this->savePreview($previewEntry, $preview);
} catch (NotPermittedException) {
throw new NotFoundException();
}
return $file;
}
}
@ -367,49 +373,10 @@ class Generator {
}
/**
* @param ISimpleFile $file
* @param string $prefix
* @psalm-param IPreview::MODE_* $mode
* @return int[]
*/
private function getPreviewSize(ISimpleFile $file, string $prefix = '') {
$size = explode('-', substr($file->getName(), strlen($prefix)));
return [(int)$size[0], (int)$size[1]];
}
/**
* @param int $width
* @param int $height
* @param bool $crop
* @param bool $max
* @param string $mimeType
* @param string $prefix
* @return string
*/
private function generatePath($width, $height, $crop, $max, $mimeType, $prefix) {
$path = $prefix . (string)$width . '-' . (string)$height;
if ($crop) {
$path .= '-crop';
}
if ($max) {
$path .= '-max';
}
$ext = $this->getExtension($mimeType);
$path .= '.' . $ext;
return $path;
}
/**
* @param int $width
* @param int $height
* @param bool $crop
* @param string $mode
* @param int $maxWidth
* @param int $maxHeight
* @return int[]
*/
private function calculateSize($width, $height, $crop, $mode, $maxWidth, $maxHeight) {
private function calculateSize(int $width, int $height, bool $crop, string $mode, int $maxWidth, int $maxHeight): array {
/*
* If we are not cropping we have to make sure the requested image
* respects the aspect ratio of the original.
@ -492,14 +459,14 @@ class Generator {
* @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid)
*/
private function generatePreview(
ISimpleFolder $previewFolder,
File $file,
IImage $maxPreview,
int $width,
int $height,
bool $crop,
int $maxWidth,
int $maxHeight,
string $prefix,
?string $version,
bool $cacheResult,
): ISimpleFile {
$preview = $maxPreview;
@ -535,82 +502,55 @@ class Generator {
self::unguardWithSemaphore($sem);
}
$path = $this->generatePath($width, $height, $crop, false, $preview->dataMimeType(), $prefix);
try {
if ($cacheResult) {
return $previewFolder->newFile($path, $preview->data());
} else {
return new InMemoryFile($path, $preview->data());
}
} catch (NotPermittedException $e) {
throw new NotFoundException();
$previewEntry = new Preview();
$previewEntry->setFileId($file->getId());
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
$previewEntry->setWidth($width);
$previewEntry->setSourceMimeType($file->getMimeType());
$previewEntry->setHeight($height);
$previewEntry->setVersion($version);
$previewEntry->setMax(false);
$previewEntry->setCropped($crop);
$previewEntry->setEncrypted(false);
$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);
} else {
return new InMemoryFile($previewEntry->getName(), $preview->data());
}
return $file;
}
/**
* @param ISimpleFile[] $files Array of FileInfo, as the result of getDirectoryListing()
* @param int $width
* @param int $height
* @param bool $crop
* @param string $mimeType
* @param string $prefix
* @return ISimpleFile
*
* @throws NotFoundException
*/
private function getCachedPreview($files, $width, $height, $crop, $mimeType, $prefix) {
$path = $this->generatePath($width, $height, $crop, false, $mimeType, $prefix);
foreach ($files as $file) {
if ($file->getName() === $path) {
$this->logger->debug('Found cached preview: {path}', ['path' => $path]);
return $file;
}
}
throw new NotFoundException();
}
/**
* Get the specific preview folder for this file
*
* @param File $file
* @return ISimpleFolder
*
* @throws InvalidPathException
* @throws NotFoundException
* @throws NotPermittedException
* @throws \OCP\DB\Exception
*/
private function getPreviewFolder(File $file) {
// Obtain file id outside of try catch block to prevent the creation of an existing folder
$fileId = (string)$file->getId();
public function savePreview(Preview $previewEntry, IImage $preview): Preview {
$previewEntry = $this->previewMapper->insert($previewEntry);
// we need to save to DB first
try {
$folder = $this->appData->getFolder($fileId);
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder($fileId);
}
return $folder;
}
/**
* @param string $mimeType
* @return null|string
* @throws \InvalidArgumentException
*/
private function getExtension($mimeType) {
switch ($mimeType) {
case 'image/png':
return 'png';
case 'image/jpeg':
return 'jpg';
case 'image/webp':
return 'webp';
case 'image/gif':
return 'gif';
default:
throw new \InvalidArgumentException('Not a valid mimetype: "' . $mimeType . '"');
if ($preview instanceof IStreamImage) {
$size = $this->storageFactory->writePreview($previewEntry, $preview->resource());
} else {
$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');
}
} catch (\Exception $e) {
$this->previewMapper->delete($previewEntry);
throw $e;
}
$previewEntry->setSize($size);
return $this->previewMapper->update($previewEntry);
}
}

View file

@ -45,11 +45,7 @@ class GeneratorHelper {
return $provider->getThumbnail($file, $maxWidth, $maxHeight) ?? false;
}
/**
* @param ISimpleFile $maxPreview
* @return IImage
*/
public function getImage(ISimpleFile $maxPreview) {
public function getImage(ISimpleFile $maxPreview): IImage {
$image = new OCPImage();
$image->loadFromData($maxPreview->getContent());
return $image;

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\StorageFactory;
use OCP\IDBConnection;
class PreviewService {
public function __construct(
private readonly StorageFactory $storageFactory,
private readonly PreviewMapper $previewMapper,
private readonly IDBConnection $connection,
) {
}
public function deletePreview(Preview $preview): void {
$this->storageFactory->deletePreview($preview);
$this->previewMapper->delete($preview);
}
/**
* Get storageId and fileIds for which we have at least one preview.
*
* @return \Generator<array{storageId: int, fileIds: int[]}>
*/
public function getAvailableFileIds(): \Generator {
$maxQb = $this->connection->getQueryBuilder();
$maxQb->select($maxQb->func()->max('id'))
->from($this->previewMapper->getTableName())
->groupBy('file_id');
$qb = $this->connection->getQueryBuilder();
$qb->select('file_id', 'storage_id')
->from($this->previewMapper->getTableName())
->where($qb->expr()->in('id', $qb->createFunction($maxQb->getSQL())));
$result = $qb->executeQuery();
$lastStorageId = -1;
/** @var int[] $fileIds */
$fileIds = [];
// Previews next to each others in the database are likely in the same storage, so group them
while ($row = $result->fetch()) {
if ($lastStorageId !== $row['storage_id']) {
if ($lastStorageId !== -1) {
yield ['storageId' => $lastStorageId, 'fileIds' => $fileIds];
$fileIds = [];
}
$lastStorageId = (int)$row['storage_id'];
}
$fileIds[] = (int)$row['file_id'];
}
if (count($fileIds) > 0) {
yield ['storageId' => $lastStorageId, 'fileIds' => $fileIds];
}
}
/**
* @return \Generator<Preview>
*/
public function getAvailablePreviewsForFile(int $fileId): \Generator {
return $this->previewMapper->getAvailablePreviewsForFile($fileId);
}
/**
* @param string[] $mimeTypes
* @return \Generator<Preview>
*/
public function getPreviewsForMimeTypes(array $mimeTypes): \Generator {
return $this->previewMapper->getPreviewsForMimeTypes($mimeTypes);
}
public function deleteAll(): void {
$lastId = 0;
while (true) {
$previews = $this->previewMapper->getPreviews($lastId, 1000);
$i = 0;
foreach ($previews as $preview) {
$this->deletePreview($preview);
$i++;
$lastId = $preview->getId();
}
if ($i !== 1000) {
break;
}
}
}
/**
* @param int[] $fileIds
* @return array<int, Preview[]>
*/
public function getAvailablePreviews(array $fileIds): array {
return $this->previewMapper->getAvailablePreviews($fileIds);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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 $stream
* @throws NotPermittedException
* @throws NotFoundException
*/
public function writePreview(Preview $preview, mixed $stream): int;
/**
* @param Preview $preview
* @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
* @throws Exception
*/
public function migratePreview(Preview $preview, SimpleFile $file): void;
/**
* @throws NotPermittedException
* @throws NotFoundException
*/
public function scan(): int;
}

View file

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use LogicException;
use OC;
use OC\Files\SimpleFS\SimpleFile;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\DB\Exception;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDBConnection;
use Override;
use Psr\Log\LoggerInterface;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class LocalPreviewStorage implements IPreviewStorage {
private readonly string $rootFolder;
private readonly string $instanceId;
public function __construct(
private readonly IConfig $config,
private readonly PreviewMapper $previewMapper,
private readonly IAppConfig $appConfig,
private readonly IDBConnection $connection,
private readonly IMimeTypeDetector $mimeTypeDetector,
private readonly LoggerInterface $logger,
) {
$this->instanceId = $this->config->getSystemValueString('instanceid');
$this->rootFolder = $this->config->getSystemValue('datadirectory', OC::$SERVERROOT . '/data');
}
#[Override]
public function writePreview(Preview $preview, mixed $stream): int {
$previewPath = $this->constructPath($preview);
$this->createParentFiles($previewPath);
return file_put_contents($previewPath, $stream);
}
#[Override]
public function readPreview(Preview $preview): mixed {
$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 {
$previewPath = $this->constructPath($preview);
if (!@unlink($previewPath) && is_file($previewPath)) {
throw new NotPermittedException('Unable to delete preview at ' . $previewPath);
}
}
public function getPreviewRootFolder(): string {
return $this->rootFolder . '/appdata_' . $this->instanceId . '/preview/';
}
private function constructPath(Preview $preview): string {
return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName();
}
private function createParentFiles(string $path): void {
$dirname = dirname($path);
@mkdir($dirname, recursive: true);
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();
if (!file_exists($sourcePath)) {
return;
}
$destinationPath = $this->constructPath($preview);
if (file_exists($destinationPath)) {
@unlink($sourcePath); // We already have a new preview, just delete the old one
return;
}
$this->createParentFiles($destinationPath);
$ok = rename($sourcePath, $destinationPath);
if (!$ok) {
throw new LogicException('Failed to move ' . $sourcePath . ' to ' . $destinationPath);
}
}
#[Override]
public function scan(): int {
$checkForFileCache = !$this->appConfig->getValueBool('core', 'previewMovedDone');
$scanner = new RecursiveDirectoryIterator($this->getPreviewRootFolder());
$previewsFound = 0;
foreach (new RecursiveIteratorIterator($scanner) as $file) {
if ($file->isFile()) {
$preview = Preview::fromPath((string)$file, $this->mimeTypeDetector);
if ($preview === false) {
$this->logger->error('Unable to parse preview information for ' . $file->getRealPath());
continue;
}
try {
$preview->setSize($file->getSize());
$preview->setMtime($file->getMtime());
$preview->setEncrypted(false);
$qb = $this->connection->getQueryBuilder();
$result = $qb->select('storage', 'etag', 'mimetype')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($preview->getFileId())))
->setMaxResults(1)
->runAcrossAllShards() // Unavoidable because we can't extract the storage_id from the preview name
->executeQuery()
->fetchAll();
if (empty($result)) {
// original file is deleted
@unlink($file->getRealPath());
continue;
}
if ($checkForFileCache) {
$relativePath = str_replace($this->rootFolder . '/', '', $file->getRealPath());
$rowAffected = $qb->delete('filecache')
->where($qb->expr()->eq('path_hash', $qb->createNamedParameter(md5($relativePath))))
->executeStatement();
if ($rowAffected > 0) {
$this->deleteParentsFromFileCache(dirname($relativePath));
}
}
$preview->setStorageId($result[0]['storage']);
$preview->setEtag($result[0]['etag']);
$preview->setSourceMimetype($result[0]['mimetype']);
// try to insert, if that fails the preview is already in the DB
$this->previewMapper->insert($preview);
// Move old flat preview to new format
$dirName = str_replace($this->getPreviewRootFolder(), '', $file->getPath());
if (preg_match('/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9]+/', $dirName) !== 1) {
$previewPath = $this->constructPath($preview);
$this->createParentFiles($previewPath);
$ok = rename($file->getRealPath(), $previewPath);
if (!$ok) {
throw new LogicException('Failed to move ' . $file->getRealPath() . ' to ' . $previewPath);
}
}
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
$previewsFound++;
}
}
return $previewsFound;
}
private function deleteParentsFromFileCache(string $dirname): void {
$qb = $this->connection->getQueryBuilder();
$result = $qb->select('fileid', 'path', 'storage', 'parent')
->from('filecache')
->where($qb->expr()->eq('path_hash', $qb->createNamedParameter(md5($dirname))))
->setMaxResults(1)
->runAcrossAllShards()
->executeQuery()
->fetchAll();
if (empty($result)) {
return;
}
$this->connection->beginTransaction();
$parentId = $result[0]['parent'];
$fileId = $result[0]['fileid'];
$storage = $result[0]['storage'];
try {
while (true) {
$qb = $this->connection->getQueryBuilder();
$childs = $qb->select('fileid', 'path', 'storage')
->from('filecache')
->where($qb->expr()->eq('parent', $qb->createNamedParameter($fileId)))
->hintShardKey('storage', $storage)
->executeQuery()
->fetchAll();
if (!empty($childs)) {
break;
}
$qb = $this->connection->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($fileId)))
->hintShardKey('storage', $result[0]['storage'])
->executeStatement();
$qb = $this->connection->getQueryBuilder();
$result = $qb->select('fileid', 'path', 'storage', 'parent')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($parentId)))
->setMaxResults(1)
->hintShardKey('storage', $storage)
->executeQuery()
->fetchAll();
if (empty($result)) {
break;
}
$fileId = $parentId;
$parentId = $result[0]['parent'];
}
} finally {
$this->connection->commit();
}
}
}

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use Icewind\Streams\CountWrapper;
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;
/**
* @psalm-import-type ObjectStoreConfig from PrimaryObjectStoreConfig
* @psalm-type ObjectStoreDefinition = array{store: IObjectStore, urn: string}
*/
class ObjectStorePreviewStorage implements IPreviewStorage {
/**
* @var array<string, array<string, IObjectStore>>
*/
private array $objectStoreCache = [];
private bool $isMultibucketPreviewDistributionEnabled;
public function __construct(
private readonly PrimaryObjectStoreConfig $objectStoreConfig,
IConfig $config,
readonly private PreviewMapper $previewMapper,
) {
$this->isMultibucketPreviewDistributionEnabled = $config->getSystemValueBool('objectstore.multibucket.preview-distribution');
}
#[Override]
public function writePreview(Preview $preview, mixed $stream): int {
$size = 0;
$countStream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void {
$size = $writtenSize;
});
[
'urn' => $urn,
'store' => $store,
] = $this->getObjectStoreInfoForNewPreview($preview);
try {
$store->writeObject($urn, $countStream);
} catch (\Exception $exception) {
throw new NotPermittedException('Unable to save preview to object store', previous: $exception);
}
return $size;
}
#[Override]
public function readPreview(Preview $preview): mixed {
[
'urn' => $urn,
'store' => $store,
] = $this->getObjectStoreInfoForExistingPreview($preview);
try {
return $store->readObject($urn);
} catch (\Exception $exception) {
throw new NotPermittedException('Unable to read preview from object store with urn:' . $urn, previous: $exception);
}
}
#[Override]
public function deletePreview(Preview $preview): void {
[
'urn' => $urn,
'store' => $store,
] = $this->getObjectStoreInfoForExistingPreview($preview);
try {
$store->deleteObject($urn);
} catch (\Exception $exception) {
throw new NotPermittedException('Unable to delete preview from object store', previous: $exception);
}
}
#[Override]
public function migratePreview(Preview $preview, SimpleFile $file): void {
// Just set the Preview::bucket and Preview::objectStore
$this->getObjectStoreInfoForNewPreview($preview, migration: true);
$this->previewMapper->update($preview);
}
/**
* @return ObjectStoreDefinition
*/
private function getObjectStoreInfoForExistingPreview(Preview $preview): array {
assert(!empty($preview->getObjectStoreName()));
assert(!empty($preview->getBucketName()));
$config = $this->objectStoreConfig->getObjectStoreConfiguration($preview->getObjectStoreName());
$config['arguments']['bucket'] = $preview->getBucketName();
$objectStoreName = $preview->getObjectStoreName();
return [
'urn' => $this->getUrn($preview, $config),
'store' => $this->getObjectStore($objectStoreName, $config),
];
}
/**
* @return ObjectStoreDefinition
*/
private function getObjectStoreInfoForNewPreview(Preview $preview, bool $migration = false): array {
// When migrating old previews, use the 'root' object store configuration
$config = $this->objectStoreConfig->getObjectStoreConfiguration($migration ? 'root' : 'preview');
$objectStoreName = $this->objectStoreConfig->resolveAlias($migration ? 'root' : 'preview');
$bucketName = $config['arguments']['bucket'];
if ($config['arguments']['multibucket']) {
if ($this->isMultibucketPreviewDistributionEnabled) {
// Spread the previews on different buckets depending on their corresponding fileId
$oldLocationArray = str_split(substr(md5((string)$preview->getFileId()), 0, 2));
$bucketNumber = hexdec('0x' . $oldLocationArray[0]) * 16 + hexdec('0x' . $oldLocationArray[0]);
$bucketName .= '-preview-' . $bucketNumber;
} else {
// Put all previews in the root (0) bucket
$bucketName .= '0';
}
}
$config['arguments']['bucket'] = $bucketName;
// Get the locationId corresponding to the bucketName and objectStoreName, this will create
// a new one, if no matching location is found in the DB.
$locationId = $this->previewMapper->getLocationId($bucketName, $objectStoreName);
$preview->setLocationId($locationId);
$preview->setObjectStoreName($objectStoreName);
$preview->setBucketName($bucketName);
return [
'urn' => $this->getUrn($preview, $config),
'store' => $this->getObjectStore($objectStoreName, $config),
];
}
private function getObjectStore(string $objectStoreName, array $config): IObjectStore {
$bucketName = $config['arguments']['bucket'];
if (!isset($this->objectStoreCache[$objectStoreName])) {
$this->objectStoreCache[$objectStoreName] = [];
$this->objectStoreCache[$objectStoreName][$bucketName] = $this->objectStoreConfig->buildObjectStore($config);
} elseif (!isset($this->objectStoreCache[$objectStoreName][$bucketName])) {
$this->objectStoreCache[$objectStoreName][$bucketName] = $this->objectStoreConfig->buildObjectStore($config);
}
return $this->objectStoreCache[$objectStoreName][$bucketName];
}
public function getUrn(Preview $preview, array $config): string {
if ($preview->getOldFileId()) {
return ($config['arguments']['objectPrefix'] ?? 'urn:oid:') . $preview->getOldFileId();
}
if (isset($config['arguments']['objectPrefix'])) {
return ($config['arguments']['objectPrefix'] . 'preview:') . $preview->getId();
} else {
return 'uri:oid:preview:' . $preview->getId();
}
}
#[Override]
public function scan(): int {
return 0;
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\Files\SimpleFS\ISimpleFile;
use Override;
class PreviewFile implements ISimpleFile {
public function __construct(
private readonly Preview $preview,
private readonly IPreviewStorage $storage,
private readonly PreviewMapper $previewMapper,
) {
}
#[Override]
public function getName(): string {
return $this->preview->getName();
}
#[Override]
public function getSize(): int|float {
return $this->preview->getSize();
}
#[Override]
public function getETag(): string {
return $this->preview->getEtag();
}
#[Override]
public function getMTime(): int {
return $this->preview->getMtime();
}
#[Override]
public function getContent(): string {
$stream = $this->storage->readPreview($this->preview);
return stream_get_contents($stream);
}
#[Override]
public function putContent($data): void {
}
#[Override]
public function delete(): void {
$this->storage->deletePreview($this->preview);
$this->previewMapper->delete($this->preview);
}
#[Override]
public function getMimeType(): string {
return $this->preview->getMimetype();
}
#[Override]
public function getExtension(): string {
return $this->preview->getExtension();
}
#[Override]
public function read() {
return $this->storage->readPreview($this->preview);
}
#[Override]
public function write() {
return false;
}
}

View file

@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use OC\Files\AppData\AppData;
use OC\SystemConfig;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFolder;
class Root extends AppData {
private $isMultibucketPreviewDistributionEnabled = false;
public function __construct(IRootFolder $rootFolder, SystemConfig $systemConfig) {
parent::__construct($rootFolder, $systemConfig, 'preview');
$this->isMultibucketPreviewDistributionEnabled = $systemConfig->getValue('objectstore.multibucket.preview-distribution', false) === true;
}
public function getFolder(string $name): ISimpleFolder {
$internalFolder = self::getInternalFolder($name);
try {
return parent::getFolder($internalFolder);
} catch (NotFoundException $e) {
/*
* The new folder structure is not found.
* Lets try the old one
*/
}
try {
return parent::getFolder($name);
} catch (NotFoundException $e) {
/*
* The old folder structure is not found.
* Lets try the multibucket fallback if available
*/
if ($this->isMultibucketPreviewDistributionEnabled) {
return parent::getFolder('old-multibucket/' . $internalFolder);
}
// when there is no further fallback just throw the exception
throw $e;
}
}
public function newFolder(string $name): ISimpleFolder {
$internalFolder = self::getInternalFolder($name);
return parent::newFolder($internalFolder);
}
/*
* Do not allow directory listing on this special root
* since it gets to big and time consuming
*/
public function getDirectoryListing(): array {
return [];
}
public static function getInternalFolder(string $name): string {
return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name;
}
public function getStorageId(): int {
return $this->getAppDataRootFolder()->getStorage()->getCache()->getNumericStorageId();
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
use OC\Files\SimpleFS\SimpleFile;
use OC\Preview\Db\Preview;
use OCP\Server;
use Override;
class StorageFactory implements IPreviewStorage {
private ?IPreviewStorage $backend = null;
public function __construct(
private readonly PrimaryObjectStoreConfig $objectStoreConfig,
) {
}
#[Override]
public function writePreview(Preview $preview, mixed $stream): int {
return $this->getBackend()->writePreview($preview, $stream);
}
#[Override]
public function readPreview(Preview $preview): mixed {
return $this->getBackend()->readPreview($preview);
}
#[Override]
public function deletePreview(Preview $preview): void {
$this->getBackend()->deletePreview($preview);
}
private function getBackend(): IPreviewStorage {
if ($this->backend) {
return $this->backend;
}
if ($this->objectStoreConfig->hasObjectStore()) {
$this->backend = Server::get(ObjectStorePreviewStorage::class);
} else {
$this->backend = Server::get(LocalPreviewStorage::class);
}
return $this->backend;
}
#[Override]
public function migratePreview(Preview $preview, SimpleFile $file): void {
$this->getBackend()->migratePreview($preview, $file);
}
#[Override]
public function scan(): int {
return $this->getBackend()->scan();
}
}

View file

@ -8,11 +8,12 @@ declare(strict_types=1);
*/
namespace OC\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\StorageFactory;
use OCP\Files\FileInfo;
use OCP\Files\Folder;
use OCP\Files\IAppData;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IDBConnection;
/**
* Class Watcher
@ -22,40 +23,44 @@ use OCP\Files\NotFoundException;
* Class that will watch filesystem activity and remove previews as needed.
*/
class Watcher {
/** @var IAppData */
private $appData;
/**
* Watcher constructor.
*
* @param IAppData $appData
*/
public function __construct(IAppData $appData) {
$this->appData = $appData;
public function __construct(
private readonly StorageFactory $storageFactory,
private readonly PreviewMapper $previewMapper,
private readonly IDBConnection $connection,
) {
}
public function postWrite(Node $node) {
public function postWrite(Node $node): void {
$this->deleteNode($node);
}
protected function deleteNode(FileInfo $node) {
protected function deleteNode(FileInfo $node): void {
// We only handle files
if ($node instanceof Folder) {
return;
}
$nodeId = $node->getId();
if (is_null($nodeId)) {
return;
}
[$node->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$nodeId]);
$this->connection->beginTransaction();
try {
if (is_null($node->getId())) {
return;
foreach ($previews as $preview) {
$this->storageFactory->deletePreview($preview);
$this->previewMapper->delete($preview);
}
$folder = $this->appData->getFolder((string)$node->getId());
$folder->delete();
} catch (NotFoundException $e) {
//Nothing to do
} finally {
$this->connection->commit();
}
}
public function versionRollback(array $data) {
public function versionRollback(array $data): void {
if (isset($data['node'])) {
$this->deleteNode($data['node']);
}

View file

@ -8,13 +8,14 @@
namespace OC;
use OC\AppFramework\Bootstrap\Coordinator;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Generator;
use OC\Preview\GeneratorHelper;
use OC\Preview\IMagickSupport;
use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\QueryException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
@ -30,7 +31,6 @@ use function array_key_exists;
class PreviewManager implements IPreview {
protected IConfig $config;
protected IRootFolder $rootFolder;
protected IAppData $appData;
protected IEventDispatcher $eventDispatcher;
private ?Generator $generator = null;
private GeneratorHelper $helper;
@ -57,7 +57,6 @@ class PreviewManager implements IPreview {
public function __construct(
IConfig $config,
IRootFolder $rootFolder,
IAppData $appData,
IEventDispatcher $eventDispatcher,
GeneratorHelper $helper,
?string $userId,
@ -68,7 +67,6 @@ class PreviewManager implements IPreview {
) {
$this->config = $config;
$this->rootFolder = $rootFolder;
$this->appData = $appData;
$this->eventDispatcher = $eventDispatcher;
$this->helper = $helper;
$this->userId = $userId;
@ -133,13 +131,14 @@ class PreviewManager implements IPreview {
$this->generator = new Generator(
$this->config,
$this,
$this->appData,
new GeneratorHelper(
$this->rootFolder,
$this->config
),
$this->eventDispatcher,
$this->container->get(LoggerInterface::class),
$this->container->get(PreviewMapper::class),
$this->container->get(StorageFactory::class),
);
}
return $this->generator;

View file

@ -13,6 +13,7 @@ use OC\Repair\AddBruteForceCleanupJob;
use OC\Repair\AddCleanupDeletedUsersBackgroundJob;
use OC\Repair\AddCleanupUpdaterBackupsJob;
use OC\Repair\AddMetadataGenerationJob;
use OC\Repair\AddMovePreviewJob;
use OC\Repair\AddRemoveOldTasksBackgroundJob;
use OC\Repair\CleanTags;
use OC\Repair\CleanUpAbandonedApps;
@ -199,6 +200,7 @@ class Repair implements IOutput {
\OCP\Server::get(RemoveLegacyDatadirFile::class),
\OCP\Server::get(AddCleanupDeletedUsersBackgroundJob::class),
\OCP\Server::get(SanitizeAccountProperties::class),
\OCP\Server::get(AddMovePreviewJob::class),
];
}

View file

@ -0,0 +1,27 @@
<?php
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Repair;
use OC\Core\BackgroundJobs\MovePreviewJob;
use OCP\BackgroundJob\IJobList;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
class AddMovePreviewJob implements IRepairStep {
public function __construct(
private IJobList $jobList,
) {
}
public function getName() {
return 'Queue a job to move the preview';
}
public function run(IOutput $output) {
$this->jobList->add(MovePreviewJob::class);
}
}

View file

@ -49,7 +49,6 @@ use OC\Files\Lock\LockManager;
use OC\Files\Mount\CacheMountProvider;
use OC\Files\Mount\LocalHomeMountProvider;
use OC\Files\Mount\ObjectHomeMountProvider;
use OC\Files\Mount\ObjectStorePreviewCacheMountProvider;
use OC\Files\Mount\RootMountProvider;
use OC\Files\Node\HookConnector;
use OC\Files\Node\LazyRoot;
@ -83,9 +82,11 @@ use OC\Notification\Manager;
use OC\OCM\Model\OCMProvider;
use OC\OCM\OCMDiscoveryService;
use OC\OCS\DiscoveryService;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\GeneratorHelper;
use OC\Preview\IMagickSupport;
use OC\Preview\MimeIconProvider;
use OC\Preview\Watcher;
use OC\Profile\ProfileManager;
use OC\Profiler\Profiler;
use OC\Remote\Api\ApiFactory;
@ -292,10 +293,6 @@ class Server extends ServerContainer implements IServerContainer {
return new PreviewManager(
$c->get(\OCP\IConfig::class),
$c->get(IRootFolder::class),
new \OC\Preview\Storage\Root(
$c->get(IRootFolder::class),
$c->get(SystemConfig::class)
),
$c->get(IEventDispatcher::class),
$c->get(GeneratorHelper::class),
$c->get(ISession::class)->get('user_id'),
@ -307,12 +304,11 @@ class Server extends ServerContainer implements IServerContainer {
});
$this->registerAlias(IMimeIconProvider::class, MimeIconProvider::class);
$this->registerService(\OC\Preview\Watcher::class, function (ContainerInterface $c) {
return new \OC\Preview\Watcher(
new \OC\Preview\Storage\Root(
$c->get(IRootFolder::class),
$c->get(SystemConfig::class)
)
$this->registerService(Watcher::class, function (ContainerInterface $c): Watcher {
return new Watcher(
$c->get(\OC\Preview\Storage\StorageFactory::class),
$c->get(PreviewMapper::class),
$c->get(IDBConnection::class),
);
});
@ -789,7 +785,6 @@ class Server extends ServerContainer implements IServerContainer {
$manager->registerHomeProvider(new LocalHomeMountProvider());
$manager->registerHomeProvider(new ObjectHomeMountProvider($objectStoreConfig));
$manager->registerRootProvider(new RootMountProvider($objectStoreConfig, $config));
$manager->registerRootProvider(new ObjectStorePreviewCacheMountProvider($logger, $config));
return $manager;
});

View file

@ -15,6 +15,7 @@ use InvalidArgumentException;
use OC\Authentication\Token\PublicKeyTokenProvider;
use OC\Authentication\Token\TokenCleanupJob;
use OC\Core\BackgroundJobs\GenerateMetadataJob;
use OC\Core\BackgroundJobs\MovePreviewJob;
use OC\Log\Rotate;
use OC\Preview\BackgroundCleanupJob;
use OC\TextProcessing\RemoveOldTasksBackgroundJob;
@ -505,6 +506,7 @@ class Setup {
$jobList->add(RemoveOldTasksBackgroundJob::class);
$jobList->add(CleanupDeletedUsers::class);
$jobList->add(GenerateMetadataJob::class);
$jobList->add(MovePreviewJob::class);
}
/**

View file

@ -84,7 +84,6 @@ abstract class QBMapper {
return $entity;
}
/**
* Creates a new entry in the db from an entity
*

View file

@ -21,6 +21,7 @@ use OCP\IPreview;
*/
class BeforePreviewFetchedEvent extends \OCP\EventDispatcher\Event {
/**
* @param null|IPreview::MODE_FILL|IPreview::MODE_COVER $mode
* @since 25.0.1
*/
public function __construct(

View file

@ -11,14 +11,13 @@ namespace OCP\Preview;
* Marks files that should keep multiple preview "versions" for the same file id
*
* Examples of this are files where the storage backend provides versioning, for those
* files, we dont have fileids for the different versions but still need to be able to generate
* files, we don't have fileIds for the different versions but still need to be able to generate
* previews for all versions
*
* @since 17.0.0
*/
interface IVersionedPreviewFile {
/**
* @return string
* @since 17.0.0
*/
public function getPreviewVersion(): string;

View file

@ -92,6 +92,7 @@
<file name="build/stubs/psr_container.php"/>
<file name="3rdparty/sabre/uri/lib/functions.php" />
<file name="build/stubs/app_api.php" />
<file name="build/stubs/php-polyfill.php" />
</stubs>
<issueHandlers>
<LessSpecificReturnStatement errorLevel="error"/>

View file

@ -1,153 +0,0 @@
<?php
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Core\Command\Preview;
use bantu\IniGetWrapper\IniGetWrapper;
use OC\Core\Command\Preview\Repair;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IConfig;
use OCP\Lock\ILockingProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Test\TestCase;
class RepairTest extends TestCase {
/** @var IConfig|MockObject */
private $config;
/** @var IRootFolder|MockObject */
private $rootFolder;
/** @var LoggerInterface|MockObject */
private $logger;
/** @var IniGetWrapper|MockObject */
private $iniGetWrapper;
/** @var InputInterface|MockObject */
private $input;
/** @var OutputInterface|MockObject */
private $output;
/** @var string */
private $outputLines = '';
/** @var Repair */
private $repair;
protected function setUp(): void {
parent::setUp();
$this->config = $this->getMockBuilder(IConfig::class)
->getMock();
$this->rootFolder = $this->getMockBuilder(IRootFolder::class)
->getMock();
$this->logger = $this->getMockBuilder(LoggerInterface::class)
->getMock();
$this->iniGetWrapper = $this->getMockBuilder(IniGetWrapper::class)
->getMock();
$this->repair = new Repair(
$this->config,
$this->rootFolder,
$this->logger,
$this->iniGetWrapper,
$this->createMock(ILockingProvider::class)
);
$this->input = $this->createMock(InputInterface::class);
$this->input->expects($this->any())
->method('getOption')
->willReturnCallback(function ($parameter) {
if ($parameter === 'batch') {
return true;
}
return null;
});
$this->output = $this->getMockBuilder(ConsoleOutput::class)
->onlyMethods(['section', 'writeln', 'getFormatter'])
->getMock();
$self = $this;
/* We need format method to return a string */
$outputFormatter = $this->createMock(OutputFormatterInterface::class);
$outputFormatter->method('isDecorated')->willReturn(false);
$outputFormatter->method('format')->willReturnArgument(0);
$this->output->expects($this->any())
->method('getFormatter')
->willReturn($outputFormatter);
$this->output->expects($this->any())
->method('writeln')
->willReturnCallback(function ($line) use ($self): void {
$self->outputLines .= $line . "\n";
});
}
public static function dataEmptyTest(): array {
/** directoryNames, expectedOutput */
return [
[
[],
'All previews are already migrated.'
],
[
[['name' => 'a'], ['name' => 'b'], ['name' => 'c']],
'All previews are already migrated.'
],
[
[['name' => '0', 'content' => ['folder', 'folder']], ['name' => 'b'], ['name' => 'c']],
'All previews are already migrated.'
],
[
[['name' => '0', 'content' => ['file', 'folder', 'folder']], ['name' => 'b'], ['name' => 'c']],
'A total of 1 preview files need to be migrated.'
],
[
[['name' => '23'], ['name' => 'b'], ['name' => 'c']],
'A total of 1 preview files need to be migrated.'
],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataEmptyTest')]
public function testEmptyExecute($directoryNames, $expectedOutput): void {
$previewFolder = $this->getMockBuilder(Folder::class)
->getMock();
$directories = array_map(function ($element) {
$dir = $this->getMockBuilder(Folder::class)
->getMock();
$dir->expects($this->any())
->method('getName')
->willReturn($element['name']);
if (isset($element['content'])) {
$list = [];
foreach ($element['content'] as $item) {
if ($item === 'file') {
$list[] = $this->getMockBuilder(Node::class)
->getMock();
} elseif ($item === 'folder') {
$list[] = $this->getMockBuilder(Folder::class)
->getMock();
}
}
$dir->expects($this->once())
->method('getDirectoryListing')
->willReturn($list);
}
return $dir;
}, $directoryNames);
$previewFolder->expects($this->once())
->method('getDirectoryListing')
->willReturn($directories);
$this->rootFolder->expects($this->once())
->method('get')
->with('appdata_/preview')
->willReturn($previewFolder);
$this->repair->run($this->input, $this->output);
$this->assertStringContainsString($expectedOutput, $this->outputLines);
}
}

View file

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Files\Mount;
use OC\Files\Mount\ObjectStorePreviewCacheMountProvider;
use OC\Files\ObjectStore\S3;
use OC\Files\Storage\StorageFactory;
use OCP\Files\Storage\IStorageFactory;
use OCP\IConfig;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
/**
* @group DB
*
* The DB permission is needed for the fake root storage initialization
*/
class ObjectStorePreviewCacheMountProviderTest extends \Test\TestCase {
/** @var ObjectStorePreviewCacheMountProvider */
protected $provider;
/** @var LoggerInterface|MockObject */
protected $logger;
/** @var IConfig|MockObject */
protected $config;
/** @var IStorageFactory|MockObject */
protected $loader;
protected function setUp(): void {
parent::setUp();
$this->logger = $this->createMock(LoggerInterface::class);
$this->config = $this->createMock(IConfig::class);
$this->loader = $this->createMock(StorageFactory::class);
$this->provider = new ObjectStorePreviewCacheMountProvider($this->logger, $this->config);
}
public function testNoMultibucketObjectStorage(): void {
$this->config->expects($this->once())
->method('getSystemValue')
->with('objectstore_multibucket')
->willReturn(null);
$this->assertEquals([], $this->provider->getRootMounts($this->loader));
}
public function testMultibucketObjectStorage(): void {
$objectstoreConfig = [
'class' => S3::class,
'arguments' => [
'bucket' => 'abc',
'num_buckets' => 64,
'key' => 'KEY',
'secret' => 'SECRET',
'hostname' => 'IP',
'port' => 'PORT',
'use_ssl' => false,
'use_path_style' => true,
],
];
$this->config->expects($this->any())
->method('getSystemValue')
->willReturnCallback(function ($config) use ($objectstoreConfig) {
if ($config === 'objectstore_multibucket') {
return $objectstoreConfig;
} elseif ($config === 'objectstore.multibucket.preview-distribution') {
return true;
}
return null;
});
$this->config->expects($this->once())
->method('getSystemValueString')
->with('instanceid')
->willReturn('INSTANCEID');
$mounts = $this->provider->getRootMounts($this->loader);
// 256 mounts for the subfolders and 1 for the fake root
$this->assertCount(257, $mounts);
// do some sanity checks if they have correct mount point paths
$this->assertEquals('/appdata_INSTANCEID/preview/0/0/', $mounts[0]->getMountPoint());
$this->assertEquals('/appdata_INSTANCEID/preview/2/5/', $mounts[37]->getMountPoint());
// also test the path of the fake bucket
$this->assertEquals('/appdata_INSTANCEID/preview/old-multibucket/', $mounts[256]->getMountPoint());
}
}

View file

@ -212,6 +212,7 @@ class PrimaryObjectStoreConfigTest extends TestCase {
$this->assertEquals([
'default' => 'server1',
'root' => 'server1',
'preview' => 'server1',
'server1' => [
'class' => StorageObjectStore::class,
'arguments' => [
@ -235,6 +236,7 @@ class PrimaryObjectStoreConfigTest extends TestCase {
$this->assertEquals([
'default' => 'server1',
'root' => 'server1',
'preview' => 'server1',
'server1' => [
'class' => StorageObjectStore::class,
'arguments' => [
@ -270,6 +272,7 @@ class PrimaryObjectStoreConfigTest extends TestCase {
$this->setConfig('objectstore', [
'default' => 'server1',
'root' => 'server2',
'preview' => 'server1',
'server1' => [
'class' => StorageObjectStore::class,
'arguments' => [

View file

@ -358,6 +358,7 @@ abstract class Storage extends \Test\TestCase {
$this->assertTrue($this->instance->file_exists($fileName));
$fh = $this->instance->fopen($fileName, 'r');
$this->assertTrue(is_resource($fh));
$content = stream_get_contents($fh);
$this->assertEquals(file_get_contents($textFile), $content);
}

View file

@ -9,16 +9,13 @@ namespace Test\Preview;
use OC\Files\Storage\Temporary;
use OC\Preview\BackgroundCleanupJob;
use OC\Preview\Storage\Root;
use OC\Preview\PreviewService;
use OC\PreviewManager;
use OC\SystemConfig;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\File;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\Server;
@ -42,6 +39,7 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
private IRootFolder $rootFolder;
private IMimeTypeLoader $mimeTypeLoader;
private ITimeFactory $timeFactory;
private PreviewService $previewService;
protected function setUp(): void {
parent::setUp();
@ -65,6 +63,7 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$this->rootFolder = Server::get(IRootFolder::class);
$this->mimeTypeLoader = Server::get(IMimeTypeLoader::class);
$this->timeFactory = Server::get(ITimeFactory::class);
$this->previewService = Server::get(PreviewService::class);
}
protected function tearDown(): void {
@ -75,21 +74,18 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$this->logout();
parent::tearDown();
}
foreach ($this->previewService->getAvailablePreviewsForFile(5) as $preview) {
$this->previewService->deletePreview($preview);
}
private function getRoot(): Root {
return new Root(
Server::get(IRootFolder::class),
Server::get(SystemConfig::class)
);
parent::tearDown();
}
private function setup11Previews(): array {
$userFolder = $this->rootFolder->getUserFolder($this->userId);
$files = [];
for ($i = 0; $i < 11; $i++) {
foreach (range(0, 10) as $i) {
$file = $userFolder->newFile($i . '.txt');
$file->putContent('hello world!');
$this->previewManager->getPreview($file);
@ -99,130 +95,50 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
return $files;
}
private function countPreviews(Root $previewRoot, array $fileIds): int {
$i = 0;
foreach ($fileIds as $fileId) {
try {
$previewRoot->getFolder((string)$fileId);
} catch (NotFoundException $e) {
continue;
}
$i++;
}
return $i;
private function countPreviews(PreviewService $previewService, array $fileIds): int {
$previews = $previewService->getAvailablePreviews($fileIds);
return array_reduce($previews, fn (int $result, array $previews) => $result + count($previews), 0);
}
public function testCleanupSystemCron(): void {
$files = $this->setup11Previews();
$fileIds = array_map(function (File $f) {
return $f->getId();
}, $files);
$fileIds = array_map(fn (File $f): int => $f->getId(), $files);
$root = $this->getRoot();
$this->assertSame(11, $this->countPreviews($root, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $root, $this->mimeTypeLoader, true);
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->previewService, true);
$job->run([]);
foreach ($files as $file) {
$file->delete();
}
$root = $this->getRoot();
$this->assertSame(11, $this->countPreviews($root, $fileIds));
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job->run([]);
$root = $this->getRoot();
$this->assertSame(0, $this->countPreviews($root, $fileIds));
$this->assertSame(0, $this->countPreviews($this->previewService, $fileIds));
}
public function testCleanupAjax(): void {
if ($this->connection->getShardDefinition('filecache')) {
$this->markTestSkipped('ajax cron is not supported for sharded setups');
return;
}
$files = $this->setup11Previews();
$fileIds = array_map(function (File $f) {
return $f->getId();
}, $files);
$fileIds = array_map(fn (File $f): int => $f->getId(), $files);
$root = $this->getRoot();
$this->assertSame(11, $this->countPreviews($root, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $root, $this->mimeTypeLoader, false);
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->previewService, false);
$job->run([]);
foreach ($files as $file) {
$file->delete();
}
$root = $this->getRoot();
$this->assertSame(11, $this->countPreviews($root, $fileIds));
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job->run([]);
$root = $this->getRoot();
$this->assertSame(1, $this->countPreviews($root, $fileIds));
$this->assertSame(1, $this->countPreviews($this->previewService, $fileIds));
$job->run([]);
$root = $this->getRoot();
$this->assertSame(0, $this->countPreviews($root, $fileIds));
}
public function testOldPreviews(): void {
if ($this->connection->getShardDefinition('filecache')) {
$this->markTestSkipped('old previews are not supported for sharded setups');
return;
}
$appdata = Server::get(IAppDataFactory::class)->get('preview');
$f1 = $appdata->newFolder('123456781');
$f1->newFile('foo.jpg', 'foo');
$f2 = $appdata->newFolder('123456782');
$f2->newFile('foo.jpg', 'foo');
$f2 = $appdata->newFolder((string)PHP_INT_MAX - 1);
$f2->newFile('foo.jpg', 'foo');
/*
* Cleanup of OldPreviewLocations should only remove numeric folders on AppData level,
* therefore these files should stay untouched.
*/
$appdata->getFolder('/')->newFile('not-a-directory', 'foo');
$appdata->getFolder('/')->newFile('133742', 'bar');
$appdata = Server::get(IAppDataFactory::class)->get('preview');
// AppData::getDirectoryListing filters all non-folders
$this->assertSame(3, count($appdata->getDirectoryListing()));
try {
$appdata->getFolder('/')->getFile('not-a-directory');
} catch (NotFoundException) {
$this->fail('Could not find file \'not-a-directory\'');
}
try {
$appdata->getFolder('/')->getFile('133742');
} catch (NotFoundException) {
$this->fail('Could not find file \'133742\'');
}
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->getRoot(), $this->mimeTypeLoader, true);
$job->run([]);
$appdata = Server::get(IAppDataFactory::class)->get('preview');
// Check if the files created above are still present
// Remember: AppData::getDirectoryListing filters all non-folders
$this->assertSame(0, count($appdata->getDirectoryListing()));
try {
$appdata->getFolder('/')->getFile('not-a-directory');
} catch (NotFoundException) {
$this->fail('Could not find file \'not-a-directory\'');
}
try {
$appdata->getFolder('/')->getFile('133742');
} catch (NotFoundException) {
$this->fail('Could not find file \'133742\'');
}
$this->assertSame(0, $this->countPreviews($this->previewService, $fileIds));
}
}

View file

@ -7,125 +7,142 @@
namespace Test\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Generator;
use OC\Preview\GeneratorHelper;
use OC\Preview\Storage\StorageFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IConfig;
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;
class GeneratorTest extends \Test\TestCase {
/** @var IConfig&\PHPUnit\Framework\MockObject\MockObject */
private $config;
abstract class VersionedPreviewFile implements IVersionedPreviewFile, File {
/** @var IPreview&\PHPUnit\Framework\MockObject\MockObject */
private $previewManager;
}
/** @var IAppData&\PHPUnit\Framework\MockObject\MockObject */
private $appData;
/** @var GeneratorHelper&\PHPUnit\Framework\MockObject\MockObject */
private $helper;
/** @var IEventDispatcher&\PHPUnit\Framework\MockObject\MockObject */
private $eventDispatcher;
/** @var Generator */
private $generator;
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger;
class GeneratorTest extends TestCase {
private IConfig&MockObject $config;
private IPreview&MockObject $previewManager;
private GeneratorHelper&MockObject $helper;
private IEventDispatcher&MockObject $eventDispatcher;
private Generator $generator;
private LoggerInterface&MockObject $logger;
private StorageFactory&MockObject $storageFactory;
private PreviewMapper&MockObject $previewMapper;
protected function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->previewManager = $this->createMock(IPreview::class);
$this->appData = $this->createMock(IAppData::class);
$this->helper = $this->createMock(GeneratorHelper::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->previewMapper = $this->createMock(PreviewMapper::class);
$this->storageFactory = $this->createMock(StorageFactory::class);
$this->generator = new Generator(
$this->config,
$this->previewManager,
$this->appData,
$this->helper,
$this->eventDispatcher,
$this->logger,
$this->previewMapper,
$this->storageFactory,
);
}
public function testGetCachedPreview(): void {
$file = $this->createMock(File::class);
private function getFile(int $fileId, string $mimeType, bool $hasVersion = false): File {
$mountPoint = $this->createMock(IMountPoint::class);
$mountPoint->method('getNumericStorageId')->willReturn(42);
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')
->willReturn('myMimeType');
->willReturn($mimeType);
$file->method('getId')
->willReturn(42);
->willReturn($fileId);
$file->method('getMountPoint')
->willReturn($mountPoint);
return $file;
}
#[TestWith([true])]
#[TestWith([false])]
public function testGetCachedPreview(bool $hasPreview): void {
$file = $this->getFile(42, 'myMimeType', $hasPreview);
$this->previewManager->method('isMimeSupported')
->with($this->equalTo('myMimeType'))
->willReturn(true);
$previewFolder = $this->createMock(ISimpleFolder::class);
$this->appData->method('getFolder')
->with($this->equalTo(42))
->willReturn($previewFolder);
$maxPreview = new Preview();
$maxPreview->setWidth(1000);
$maxPreview->setHeight(1000);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setCropped(false);
$maxPreview->setStorageId(1);
$maxPreview->setVersion($hasPreview ? 'abc' : null);
$maxPreview->setMimeType('image/png');
$maxPreview = $this->createMock(ISimpleFile::class);
$maxPreview->method('getName')
->willReturn('1000-1000-max.png');
$maxPreview->method('getSize')->willReturn(1000);
$maxPreview->method('getMimeType')
->willReturn('image/png');
$previewFile = new Preview();
$previewFile->setWidth(256);
$previewFile->setHeight(256);
$previewFile->setMax(false);
$previewFile->setSize(1000);
$previewFile->setVersion($hasPreview ? 'abc' : null);
$previewFile->setCropped(false);
$previewFile->setStorageId(1);
$previewFile->setMimeType('image/png');
$previewFile = $this->createMock(ISimpleFile::class);
$previewFile->method('getSize')->willReturn(1000);
$previewFile->method('getName')->willReturn('256-256.png');
$previewFolder->method('getDirectoryListing')
->willReturn([$maxPreview, $previewFile]);
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
->willReturn([42 => [
$maxPreview,
$previewFile,
]]);
$this->eventDispatcher->expects($this->once())
->method('dispatchTyped')
->with(new BeforePreviewFetchedEvent($file, 100, 100, false, IPreview::MODE_FILL, null));
$result = $this->generator->getPreview($file, 100, 100);
$this->assertSame($previewFile, $result);
$this->assertSame($hasPreview ? 'abc-256-256.png' : '256-256.png', $result->getName());
$this->assertSame(1000, $result->getSize());
}
public function testGetNewPreview(): void {
$file = $this->createMock(File::class);
$file->method('isReadable')
->willReturn(true);
$file->method('getMimeType')
->willReturn('myMimeType');
$file->method('getId')
->willReturn(42);
#[TestWith([true])]
#[TestWith([false])]
public function testGetNewPreview(bool $hasVersion): void {
$file = $this->getFile(42, 'myMimeType', $hasVersion);
$this->previewManager->method('isMimeSupported')
->with($this->equalTo('myMimeType'))
->willReturn(true);
$previewFolder = $this->createMock(ISimpleFolder::class);
$this->appData->method('getFolder')
->with($this->equalTo(42))
->willThrowException(new NotFoundException());
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
->willReturn([42 => []]);
$this->appData->method('newFolder')
->with($this->equalTo(42))
->willReturn($previewFolder);
$this->config->method('getSystemValue')
$this->config->method('getSystemValueString')
->willReturnCallback(function ($key, $default) {
return $default;
});
@ -175,7 +192,7 @@ class GeneratorTest extends \Test\TestCase {
$image->method('dataMimeType')->willReturn('image/png');
$this->helper->method('getThumbnail')
->willReturnCallback(function ($provider, $file, $x, $y) use ($invalidProvider, $validProvider, $image) {
->willReturnCallback(function ($provider, $file, $x, $y) use ($invalidProvider, $validProvider, $image): false|IImage {
if ($provider === $validProvider) {
return $image;
} else {
@ -186,29 +203,39 @@ class GeneratorTest extends \Test\TestCase {
$image->method('data')
->willReturn('my data');
$maxPreview = $this->createMock(ISimpleFile::class);
$maxPreview->method('getName')->willReturn('2048-2048-max.png');
$maxPreview->method('getMimeType')->willReturn('image/png');
$maxPreview->method('getSize')->willReturn(1000);
$this->previewMapper->method('insert')
->willReturnCallback(fn (Preview $preview): Preview => $preview);
$previewFile = $this->createMock(ISimpleFile::class);
$previewFile->method('getSize')->willReturn(1000);
$this->previewMapper->method('update')
->willReturnCallback(fn (Preview $preview): Preview => $preview);
$previewFolder->method('getDirectoryListing')
->willReturn([]);
$previewFolder->method('newFile')
->willReturnMap([
['2048-2048-max.png', 'my data', $maxPreview],
['256-256.png', 'my resized data', $previewFile],
]);
$previewFolder->method('getFile')
->with($this->equalTo('256-256.png'))
->willThrowException(new NotFoundException());
$this->storageFactory->method('writePreview')
->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());
});
$image = $this->getMockImage(2048, 2048, 'my resized data');
$this->helper->method('getImage')
->with($this->equalTo($maxPreview))
->willReturn($image);
$this->eventDispatcher->expects($this->once())
@ -216,39 +243,32 @@ class GeneratorTest extends \Test\TestCase {
->with(new BeforePreviewFetchedEvent($file, 100, 100, false, IPreview::MODE_FILL, null));
$result = $this->generator->getPreview($file, 100, 100);
$this->assertSame($previewFile, $result);
$this->assertSame($hasVersion ? 'abc-256-256.png' : '256-256.png', $result->getName());
$this->assertSame(1000, $result->getSize());
}
public function testInvalidMimeType(): void {
$this->expectException(NotFoundException::class);
$file = $this->createMock(File::class);
$file->method('isReadable')
->willReturn(true);
$file->method('getId')
->willReturn(42);
$file = $this->getFile(42, 'invalidType');
$this->previewManager->method('isMimeSupported')
->with('invalidType')
->willReturn(false);
$previewFolder = $this->createMock(ISimpleFolder::class);
$this->appData->method('getFolder')
->with($this->equalTo(42))
->willReturn($previewFolder);
$maxPreview = new Preview();
$maxPreview->setWidth(2048);
$maxPreview->setHeight(2048);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(null);
$maxPreview->setMimetype('image/png');
$maxPreview = $this->createMock(ISimpleFile::class);
$maxPreview->method('getName')
->willReturn('2048-2048-max.png');
$maxPreview->method('getMimeType')
->willReturn('image/png');
$previewFolder->method('getDirectoryListing')
->willReturn([$maxPreview]);
$previewFolder->method('getFile')
->with($this->equalTo('1024-512-crop.png'))
->willThrowException(new NotFoundException());
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
->willReturn([42 => [
$maxPreview,
]]);
$this->eventDispatcher->expects($this->once())
->method('dispatchTyped')
@ -258,31 +278,31 @@ class GeneratorTest extends \Test\TestCase {
}
public function testReturnCachedPreviewsWithoutCheckingSupportedMimetype(): void {
$file = $this->createMock(File::class);
$file->method('isReadable')
->willReturn(true);
$file->method('getId')
->willReturn(42);
$file = $this->getFile(42, 'myMimeType');
$maxPreview = new Preview();
$maxPreview->setWidth(2048);
$maxPreview->setHeight(2048);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(null);
$maxPreview->setMimeType('image/png');
$previewFolder = $this->createMock(ISimpleFolder::class);
$this->appData->method('getFolder')
->with($this->equalTo(42))
->willReturn($previewFolder);
$previewFile = new Preview();
$previewFile->setWidth(1024);
$previewFile->setHeight(512);
$previewFile->setMax(false);
$previewFile->setSize(1000);
$previewFile->setCropped(true);
$previewFile->setVersion(null);
$previewFile->setMimeType('image/png');
$maxPreview = $this->createMock(ISimpleFile::class);
$maxPreview->method('getName')
->willReturn('2048-2048-max.png');
$maxPreview->method('getSize')->willReturn(1000);
$maxPreview->method('getMimeType')
->willReturn('image/png');
$preview = $this->createMock(ISimpleFile::class);
$preview->method('getSize')->willReturn(1000);
$preview->method('getName')->willReturn('1024-512-crop.png');
$previewFolder->method('getDirectoryListing')
->willReturn([$maxPreview, $preview]);
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
->willReturn([42 => [
$maxPreview,
$previewFile,
]]);
$this->previewManager->expects($this->never())
->method('isMimeSupported');
@ -292,25 +312,15 @@ class GeneratorTest extends \Test\TestCase {
->with(new BeforePreviewFetchedEvent($file, 1024, 512, true, IPreview::MODE_COVER, 'invalidType'));
$result = $this->generator->getPreview($file, 1024, 512, true, IPreview::MODE_COVER, 'invalidType');
$this->assertSame($preview, $result);
$this->assertSame('1024-512-crop.png', $result->getName());
}
public function testNoProvider(): void {
$file = $this->createMock(File::class);
$file->method('isReadable')
->willReturn(true);
$file->method('getMimeType')
->willReturn('myMimeType');
$file->method('getId')
->willReturn(42);
$file = $this->getFile(42, 'myMimeType');
$previewFolder = $this->createMock(ISimpleFolder::class);
$this->appData->method('getFolder')
->with($this->equalTo(42))
->willReturn($previewFolder);
$previewFolder->method('getDirectoryListing')
->willReturn([]);
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
->willReturn([42 => []]);
$this->previewManager->method('getProviders')
->willReturn([]);
@ -323,7 +333,7 @@ class GeneratorTest extends \Test\TestCase {
$this->generator->getPreview($file, 100, 100);
}
private function getMockImage($width, $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);
@ -380,65 +390,52 @@ class GeneratorTest extends \Test\TestCase {
];
}
/**
*
* @param int $maxX
* @param int $maxY
* @param int $reqX
* @param int $reqY
* @param bool $crop
* @param string $mode
* @param int $expectedX
* @param int $expectedY
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataSize')]
public function testCorrectSize($maxX, $maxY, $reqX, $reqY, $crop, $mode, $expectedX, $expectedY): void {
$file = $this->createMock(File::class);
$file->method('isReadable')
->willReturn(true);
$file->method('getMimeType')
->willReturn('myMimeType');
$file->method('getId')
->willReturn(42);
#[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');
$this->previewManager->method('isMimeSupported')
->with($this->equalTo('myMimeType'))
->willReturn(true);
$previewFolder = $this->createMock(ISimpleFolder::class);
$this->appData->method('getFolder')
->with($this->equalTo(42))
->willReturn($previewFolder);
$maxPreview = new Preview();
$maxPreview->setWidth($maxX);
$maxPreview->setHeight($maxY);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(null);
$maxPreview->setMimeType('image/png');
$maxPreview = $this->createMock(ISimpleFile::class);
$maxPreview->method('getName')
->willReturn($maxX . '-' . $maxY . '-max.png');
$maxPreview->method('getMimeType')
->willReturn('image/png');
$maxPreview->method('getSize')->willReturn(1000);
$this->assertSame($maxPreview->getName(), $maxX . '-' . $maxY . '-max.png');
$this->assertSame($maxPreview->getMimeType(), 'image/png');
$previewFolder->method('getDirectoryListing')
->willReturn([$maxPreview]);
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
->willReturn([42 => [
$maxPreview,
]]);
$filename = $expectedX . '-' . $expectedY;
if ($crop) {
$filename .= '-crop';
}
$filename .= '.png';
$previewFolder->method('getFile')
->with($this->equalTo($filename))
->willThrowException(new NotFoundException());
$image = $this->getMockImage($maxX, $maxY);
$this->helper->method('getImage')
->with($this->equalTo($maxPreview))
->willReturn($image);
$preview = $this->createMock(ISimpleFile::class);
$preview->method('getSize')->willReturn(1000);
$previewFolder->method('newFile')
->with($this->equalTo($filename))
->willReturn($preview);
$this->previewMapper->method('insert')
->willReturnCallback(function (Preview $preview) use ($filename): Preview {
$this->assertSame($preview->getName(), $filename);
return $preview;
});
$this->previewMapper->method('update')
->willReturnCallback(fn (Preview $preview): Preview => $preview);
$this->storageFactory->method('writePreview')
->willReturn(1000);
$this->eventDispatcher->expects($this->once())
->method('dispatchTyped')
@ -446,9 +443,9 @@ class GeneratorTest extends \Test\TestCase {
$result = $this->generator->getPreview($file, $reqX, $reqY, $crop, $mode);
if ($expectedX === $maxX && $expectedY === $maxY) {
$this->assertSame($maxPreview, $result);
$this->assertSame($maxPreview->getName(), $result->getName());
} else {
$this->assertSame($preview, $result);
$this->assertSame($filename, $result->getName());
}
}

View file

@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace lib\Preview;
use OC\Core\BackgroundJobs\MovePreviewJob;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\PreviewService;
use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
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;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
/**
* @group DB
*/
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;
private IMimeTypeLoader&MockObject $mimeTypeLoader;
private IMimeTypeDetector&MockObject $mimeTypeDetector;
private LoggerInterface&MockObject $logger;
public function setUp(): void {
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')
->willReturn(false);
$this->appConfig->expects($this->any())
->method('setValueBool')
->willReturn(true);
$this->storageFactory = Server::get(StorageFactory::class);
$this->previewService = Server::get(PreviewService::class);
$this->db = Server::get(IDBConnection::class);
$qb = $this->db->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter(5)))
->executeStatement();
$qb = $this->db->getQueryBuilder();
$qb->insert('filecache')
->values([
'fileid' => $qb->createNamedParameter(5),
'storage' => $qb->createNamedParameter(1),
'path' => $qb->createNamedParameter('test/abc'),
'path_hash' => $qb->createNamedParameter(md5('test')),
'parent' => $qb->createNamedParameter(0),
'name' => $qb->createNamedParameter('abc'),
'mimetype' => $qb->createNamedParameter(42),
'size' => $qb->createNamedParameter(1000),
'mtime' => $qb->createNamedParameter(1000),
'storage_mtime' => $qb->createNamedParameter(1000),
'encrypted' => $qb->createNamedParameter(0),
'unencrypted_size' => $qb->createNamedParameter(0),
'etag' => $qb->createNamedParameter('abcdefg'),
'permissions' => $qb->createNamedParameter(0),
'checksum' => $qb->createNamedParameter('abcdefg'),
])->executeStatement();
$this->mimeTypeDetector = $this->createMock(IMimeTypeDetector::class);
$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);
}
public function tearDown(): void {
foreach ($this->previewAppData->getDirectoryListing() as $folder) {
$folder->delete();
}
$this->previewService->deleteAll();
$qb = $this->db->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter(5)))
->executeStatement();
}
#[TestDox('Test the migration from the legacy flat hierarchy to the new database format')]
public function testMigrationLegacyPath(): void {
$folder = $this->previewAppData->newFolder('5');
$folder->newFile('64-64-crop.jpg', 'abcdefg');
$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->getAvailablePreviewsForFile(5))));
$job = new MovePreviewJob(
Server::get(ITimeFactory::class),
$this->appConfig,
$this->config,
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
Server::get(IRootFolder::class),
$this->mimeTypeDetector,
$this->mimeTypeLoader,
$this->logger,
Server::get(IAppDataFactory::class),
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
}
private static function getInternalFolder(string $name): string {
return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name;
}
#[TestDox("Test the migration from the 'new' nested hierarchy to the database format")]
public function testMigrationPath(): void {
$folder = $this->previewAppData->newFolder(self::getInternalFolder((string)5));
$folder->newFile('64-64-crop.jpg', 'abcdefg');
$folder->newFile('128-128-crop.png', 'abcdefg');
$folder = $this->previewAppData->getFolder(self::getInternalFolder((string)5));
$this->assertEquals(2, count($folder->getDirectoryListing()));
$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),
Server::get(IRootFolder::class),
$this->mimeTypeDetector,
$this->mimeTypeLoader,
$this->logger,
Server::get(IAppDataFactory::class)
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
}
#[TestDox("Test the migration from the 'new' nested hierarchy to the database format")]
public function testMigrationPathWithVersion(): void {
$folder = $this->previewAppData->newFolder(self::getInternalFolder((string)5));
// No version
$folder->newFile('128-128-crop.png', 'abcdefg');
$folder->newFile('256-256-max.png', 'abcdefg');
$folder->newFile('128-128.png', 'abcdefg');
// Version 1000
$folder->newFile('1000-128-128-crop.png', 'abcdefg');
$folder->newFile('1000-256-256-max.png', 'abcdefg');
$folder->newFile('1000-128-128.png', 'abcdefg');
// Version 1001
$folder->newFile('1001-128-128-crop.png', 'abcdefg');
$folder->newFile('1001-256-256-max.png', 'abcdefg');
$folder->newFile('1001-128-128.png', 'abcdefg');
$folder = $this->previewAppData->getFolder(self::getInternalFolder((string)5));
$this->assertEquals(9, count($folder->getDirectoryListing()));
$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),
Server::get(IRootFolder::class),
$this->mimeTypeDetector,
$this->mimeTypeLoader,
$this->logger,
Server::get(IAppDataFactory::class)
);
$this->invokePrivate($job, 'run', [[]]);
$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($this->mimeTypeLoader)] = $preview->getVersion();
}
$this->assertEquals([
'1000-128-128-crop.png' => 1000,
'1000-128-128.png' => 1000,
'1000-256-256-max.png' => 1000,
'1001-128-128-crop.png' => 1001,
'1001-128-128.png' => 1001,
'1001-256-256-max.png' => 1001,
'128-128-crop.png' => null,
'128-128.png' => null,
'256-256-max.png' => null,
], $nameVersionMapping);
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\IDBConnection;
use OCP\Server;
use Test\TestCase;
/**
* @group DB
*/
class PreviewMapperTest extends TestCase {
private PreviewMapper $previewMapper;
private IDBConnection $connection;
public function setUp(): void {
$this->previewMapper = Server::get(PreviewMapper::class);
$this->connection = Server::get(IDBConnection::class);
}
public function testGetAvailablePreviews(): void {
// Empty
$this->assertEquals([], $this->previewMapper->getAvailablePreviews([]));
// No preview available
$this->assertEquals([42 => []], $this->previewMapper->getAvailablePreviews([42]));
$this->createPreviewForFileId(42);
$previews = $this->previewMapper->getAvailablePreviews([42]);
$this->assertNotEmpty($previews[42]);
$this->assertNull($previews[42][0]->getLocationId());
$this->assertNull($previews[42][0]->getBucketName());
$this->assertNull($previews[42][0]->getObjectStoreName());
$this->createPreviewForFileId(43, 2);
$previews = $this->previewMapper->getAvailablePreviews([43]);
$this->assertNotEmpty($previews[43]);
$this->assertEquals('preview-2', $previews[43][0]->getBucketName());
$this->assertEquals('default', $previews[43][0]->getObjectStoreName());
}
private function createPreviewForFileId(int $fileId, ?int $bucket = null): void {
$locationId = null;
if ($bucket) {
$qb = $this->connection->getQueryBuilder();
$qb->insert('preview_locations')
->values([
'bucket_name' => $qb->createNamedParameter('preview-' . $bucket),
'object_store_name' => $qb->createNamedParameter('default'),
]);
$qb->executeStatement();
$locationId = $qb->getLastInsertId();
}
$preview = new Preview();
$preview->setFileId($fileId);
$preview->setStorageId(1);
$preview->setCropped(true);
$preview->setMax(true);
$preview->setWidth(100);
$preview->setSourceMimeType('image/jpeg');
$preview->setHeight(100);
$preview->setSize(100);
$preview->setMtime(time());
$preview->setMimetype('image/jpeg');
$preview->setEtag('abcdefg');
if ($locationId !== null) {
$preview->setLocationId($locationId);
}
$this->previewMapper->insert($preview);
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\PreviewService;
use OCP\Server;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
/**
* @group DB
*/
#[CoversClass(PreviewService::class)]
class PreviewServiceTest extends TestCase {
private PreviewService $previewService;
private PreviewMapper $previewMapper;
protected function setUp(): void {
$this->previewService = Server::get(PreviewService::class);
$this->previewMapper = Server::get(PreviewMapper::class);
$this->previewService->deleteAll();
}
public function tearDown(): void {
$this->previewService->deleteAll();
}
public function testGetAvailableFileIds(): void {
foreach (range(1, 20) as $i) {
$preview = new Preview();
$preview->setFileId($i % 10);
$preview->setStorageId(1);
$preview->setWidth($i);
$preview->setHeight($i);
$preview->setMax(true);
$preview->setSourceMimeType('image/jpeg');
$preview->setCropped(true);
$preview->setEncrypted(false);
$preview->setMimetype('image/jpeg');
$preview->setEtag('abc');
$preview->setMtime((new \DateTime())->getTimestamp());
$preview->setSize(0);
$this->previewMapper->insert($preview);
}
$files = iterator_to_array($this->previewService->getAvailableFileIds());
$this->assertCount(1, $files);
$this->assertCount(10, $files[0]['fileIds']);
}
}

View file

@ -9,7 +9,7 @@
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level
// when updating major/minor version number.
$OC_Version = [33, 0, 0, 0];
$OC_Version = [33, 0, 0, 1];
// The human-readable string
$OC_VersionString = '33.0.0 dev';