diff options
Diffstat (limited to 'lib/private/Preview')
44 files changed, 3076 insertions, 0 deletions
diff --git a/lib/private/Preview/BMP.php b/lib/private/Preview/BMP.php new file mode 100644 index 00000000000..f275aecf0cf --- /dev/null +++ b/lib/private/Preview/BMP.php @@ -0,0 +1,17 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +class BMP extends Image { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/bmp/'; + } +} diff --git a/lib/private/Preview/BackgroundCleanupJob.php b/lib/private/Preview/BackgroundCleanupJob.php new file mode 100644 index 00000000000..3138abb1bf9 --- /dev/null +++ b/lib/private/Preview/BackgroundCleanupJob.php @@ -0,0 +1,201 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OC\Preview\Storage\Root; +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; + +class BackgroundCleanupJob extends TimedJob { + + public function __construct( + ITimeFactory $timeFactory, + private IDBConnection $connection, + private Root $previewFolder, + private IMimeTypeLoader $mimeTypeLoader, + private bool $isCLI, + ) { + parent::__construct($timeFactory); + // Run at most once an hour + $this->setInterval(60 * 60); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + public function run($argument) { + foreach ($this->getDeletedFiles() as $fileId) { + try { + $preview = $this->previewFolder->getFolder((string)$fileId); + $preview->delete(); + } catch (NotFoundException $e) { + // continue + } catch (NotPermittedException $e) { + // continue + } + } + } + + 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); + } + + 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. + * + * A delete request is usually an HTTP request. + * The preview deleting is done by a background job to avoid timeouts. + * + * Previews for a file are stored within a folder in appdata_/preview using the fileid as folder name. + * Preview folders in oc_filecache are identified by a.storage, a.path (cf. $like) and a.mimetype. + * + * To find preview folders to delete, we query oc_filecache for a preview folder in app data, matching the preview folder structure + * and use the name to left join oc_filecache on a.name = b.fileid. A left join returns all rows from the left table (a), + * even if there are no matches in the right table (b). + * + * 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' + )) + ->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'))) + ) + ); + + if (!$this->isCLI) { + $qb->setMaxResults(10); + } + + $cursor = $qb->executeQuery(); + + while ($row = $cursor->fetch()) { + yield $row['name']; + } + + $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 { + $qb = $this->connection->getQueryBuilder(); + $qb->select('fileid') + ->from('filecache') + ->where($qb->expr()->in('fileid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $found = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + return array_diff($ids, $found); + } +} diff --git a/lib/private/Preview/Bitmap.php b/lib/private/Preview/Bitmap.php new file mode 100644 index 00000000000..a3d5fbfd4ec --- /dev/null +++ b/lib/private/Preview/Bitmap.php @@ -0,0 +1,128 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use Imagick; +use OCP\Files\File; +use OCP\IImage; +use Psr\Log\LoggerInterface; + +/** + * Creates a PNG preview using ImageMagick via the PECL extension + * + * @package OC\Preview + */ +abstract class Bitmap extends ProviderV2 { + /** + * List of MIME types that this preview provider is allowed to process. + * + * These should correspond to the MIME types *identified* by Imagemagick + * for files to be processed by this provider. These do / will not + * necessarily need to match the MIME types stored in the database + * (which are identified by IMimeTypeDetector). + * + * @return string Regular expression + */ + abstract protected function getAllowedMimeTypes(): string; + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $tmpPath = $this->getLocalFile($file); + if ($tmpPath === false) { + \OC::$server->get(LoggerInterface::class)->error( + 'Failed to get thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + // Creates \Imagick object from bitmap or vector file + try { + $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY); + } catch (\Exception $e) { + \OC::$server->get(LoggerInterface::class)->info( + 'File: ' . $file->getPath() . ' Imagick says:', + [ + 'exception' => $e, + 'app' => 'core', + ] + ); + return null; + } + + $this->cleanTmpFiles(); + + //new bitmap image object + $image = new \OCP\Image(); + $image->loadFromData((string)$bp); + //check if image object is valid + return $image->valid() ? $image : null; + } + + /** + * Returns a preview of maxX times maxY dimensions in PNG format + * + * * The default resolution is already 72dpi, no need to change it for a bitmap output + * * It's possible to have proper colour conversion using profileimage(). + * ICC profiles are here: http://www.color.org/srgbprofiles.xalter + * * It's possible to Gamma-correct an image via gammaImage() + * + * @param string $tmpPath the location of the file to convert + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + * + * @throws \Exception + */ + private function getResizedPreview($tmpPath, $maxX, $maxY) { + $bp = new Imagick(); + + // Validate mime type + $bp->pingImage($tmpPath . '[0]'); + $mimeType = $bp->getImageMimeType(); + if (!preg_match($this->getAllowedMimeTypes(), $mimeType)) { + throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); + } + + // Layer 0 contains either the bitmap or a flat representation of all vector layers + $bp->readImage($tmpPath . '[0]'); + + $bp = $this->resize($bp, $maxX, $maxY); + + $bp->setImageFormat('png'); + + return $bp; + } + + /** + * Returns a resized \Imagick object + * + * If you want to know more on the various methods available to resize an + * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im + * + * @param \Imagick $bp + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + */ + private function resize($bp, $maxX, $maxY) { + [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry()); + + // We only need to resize a preview which doesn't fit in the maximum dimensions + if ($previewWidth > $maxX || $previewHeight > $maxY) { + // TODO: LANCZOS is the default filter, CATROM could bring similar results faster + $bp->resizeImage($maxX, $maxY, imagick::FILTER_LANCZOS, 1, true); + } + + return $bp; + } +} diff --git a/lib/private/Preview/Bundled.php b/lib/private/Preview/Bundled.php new file mode 100644 index 00000000000..6100e8262a4 --- /dev/null +++ b/lib/private/Preview/Bundled.php @@ -0,0 +1,43 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OC\Archive\ZIP; +use OCP\Files\File; +use OCP\IImage; + +/** + * Extracts a preview from files that embed them in an ZIP archive + */ +abstract class Bundled extends ProviderV2 { + protected function extractThumbnail(File $file, string $path): ?IImage { + if ($file->getSize() === 0) { + return null; + } + + $sourceTmp = \OC::$server->getTempManager()->getTemporaryFile(); + $targetTmp = \OC::$server->getTempManager()->getTemporaryFile(); + $this->tmpFiles[] = $sourceTmp; + $this->tmpFiles[] = $targetTmp; + + try { + $content = $file->fopen('r'); + file_put_contents($sourceTmp, $content); + + $zip = new ZIP($sourceTmp); + $zip->extractFile($path, $targetTmp); + + $image = new \OCP\Image(); + $image->loadFromFile($targetTmp); + $image->fixOrientation(); + + return $image; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/lib/private/Preview/EMF.php b/lib/private/Preview/EMF.php new file mode 100644 index 00000000000..a3c48e7d8aa --- /dev/null +++ b/lib/private/Preview/EMF.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Preview; + +class EMF extends Office { + public function getMimeType(): string { + return '/image\/emf/'; + } +} diff --git a/lib/private/Preview/Font.php b/lib/private/Preview/Font.php new file mode 100644 index 00000000000..79e537f6ffb --- /dev/null +++ b/lib/private/Preview/Font.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +// .otf, .ttf and .pfb +class Font extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/(?:font-sfnt|x-font$)/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/(application|image)\/(?:font-sfnt|x-font|x-otf|x-ttf|x-pfb$)/'; + } +} diff --git a/lib/private/Preview/GIF.php b/lib/private/Preview/GIF.php new file mode 100644 index 00000000000..941ef68648a --- /dev/null +++ b/lib/private/Preview/GIF.php @@ -0,0 +1,17 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +class GIF extends Image { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/gif/'; + } +} diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php new file mode 100644 index 00000000000..4a7341896ef --- /dev/null +++ b/lib/private/Preview/Generator.php @@ -0,0 +1,616 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +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; +use OCP\IStreamImage; +use OCP\Preview\BeforePreviewFetchedEvent; +use OCP\Preview\IProviderV2; +use OCP\Preview\IVersionedPreviewFile; +use Psr\Log\LoggerInterface; + +class Generator { + public const SEMAPHORE_ID_ALL = 0x0a11; + public const SEMAPHORE_ID_NEW = 0x07ea; + + public function __construct( + private IConfig $config, + private IPreview $previewManager, + private IAppData $appData, + private GeneratorHelper $helper, + private IEventDispatcher $eventDispatcher, + private LoggerInterface $logger, + ) { + } + + /** + * Returns a preview of a file + * + * The cache is searched first and if nothing usable was found then a preview is + * generated by one of the providers + * + * @return ISimpleFile + * @throws NotFoundException + * @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid) + */ + public function getPreview( + File $file, + int $width = -1, + int $height = -1, + bool $crop = false, + string $mode = IPreview::MODE_FILL, + ?string $mimeType = null, + bool $cacheResult = true, + ): ISimpleFile { + $specification = [ + 'width' => $width, + 'height' => $height, + 'crop' => $crop, + 'mode' => $mode, + ]; + + $this->eventDispatcher->dispatchTyped(new BeforePreviewFetchedEvent( + $file, + $width, + $height, + $crop, + $mode, + $mimeType, + )); + + $this->logger->debug('Requesting preview for {path} with width={width}, height={height}, crop={crop}, mode={mode}, mimeType={mimeType}', [ + 'path' => $file->getPath(), + 'width' => $width, + 'height' => $height, + 'crop' => $crop, + 'mode' => $mode, + 'mimeType' => $mimeType, + ]); + + + // since we only ask for one preview, and the generate method return the last one it created, it returns the one we want + return $this->generatePreviews($file, [$specification], $mimeType, $cacheResult); + } + + /** + * Generates previews of a file + * + * @throws NotFoundException + * @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid) + */ + public function generatePreviews(File $file, array $specifications, ?string $mimeType = null, bool $cacheResult = true): ISimpleFile { + //Make sure that we can read the file + if (!$file->isReadable()) { + $this->logger->warning('Cannot read file: {path}, skipping preview generation.', ['path' => $file->getPath()]); + throw new NotFoundException('Cannot read file'); + } + + if ($mimeType === null) { + $mimeType = $file->getMimeType(); + } + + $previewFolder = $this->getPreviewFolder($file); + // List every existing preview first instead of trying to find them one by one + $previewFiles = $previewFolder->getDirectoryListing(); + + $previewVersion = ''; + if ($file instanceof IVersionedPreviewFile) { + $previewVersion = $file->getPreviewVersion() . '-'; + } + + // Get the max preview and infer the max preview sizes from that + $maxPreview = $this->getMaxPreview($previewFolder, $previewFiles, $file, $mimeType, $previewVersion); + $maxPreviewImage = null; // only load the image when we need it + if ($maxPreview->getSize() === 0) { + $maxPreview->delete(); + $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); + + if ($maxWidth <= 0 || $maxHeight <= 0) { + throw new NotFoundException('The maximum preview sizes are zero or less pixels'); + } + + $preview = null; + + foreach ($specifications as $specification) { + $width = $specification['width'] ?? -1; + $height = $specification['height'] ?? -1; + $crop = $specification['crop'] ?? false; + $mode = $specification['mode'] ?? IPreview::MODE_FILL; + + // If both width and height are -1 we just want the max preview + if ($width === -1 && $height === -1) { + $width = $maxWidth; + $height = $maxHeight; + } + + // Calculate the preview size + [$width, $height] = $this->calculateSize($width, $height, $crop, $mode, $maxWidth, $maxHeight); + + // 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; + 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) { + if (!$this->previewManager->isMimeSupported($mimeType)) { + throw new NotFoundException(); + } + + if ($maxPreviewImage === null) { + $maxPreviewImage = $this->helper->getImage($maxPreview); + } + + $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; + } + } catch (\InvalidArgumentException $e) { + throw new NotFoundException('', 0, $e); + } + + if ($preview->getSize() === 0) { + $preview->delete(); + throw new NotFoundException('Cached preview size 0, invalid!'); + } + } + assert($preview !== 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. + if ($maxPreviewImage instanceof \OCP\Image) { + $maxPreviewImage->destroy(); + } + + return $preview; + } + + /** + * Acquire a semaphore of the specified id and concurrency, blocking if necessary. + * Return an identifier of the semaphore on success, which can be used to release it via + * {@see Generator::unguardWithSemaphore()}. + * + * @param int $semId + * @param int $concurrency + * @return false|\SysvSemaphore the semaphore on success or false on failure + */ + public static function guardWithSemaphore(int $semId, int $concurrency) { + if (!extension_loaded('sysvsem')) { + return false; + } + $sem = sem_get($semId, $concurrency); + if ($sem === false) { + return false; + } + if (!sem_acquire($sem)) { + return false; + } + return $sem; + } + + /** + * Releases the semaphore acquired from {@see Generator::guardWithSemaphore()}. + * + * @param false|\SysvSemaphore $semId the semaphore identifier returned by guardWithSemaphore + * @return bool + */ + public static function unguardWithSemaphore(false|\SysvSemaphore $semId): bool { + if ($semId === false || !($semId instanceof \SysvSemaphore)) { + return false; + } + return sem_release($semId); + } + + /** + * Get the number of concurrent threads supported by the host. + * + * @return int number of concurrent threads, or 0 if it cannot be determined + */ + public static function getHardwareConcurrency(): int { + static $width; + + if (!isset($width)) { + if (function_exists('ini_get')) { + $openBasedir = ini_get('open_basedir'); + if (empty($openBasedir) || strpos($openBasedir, '/proc/cpuinfo') !== false) { + $width = is_readable('/proc/cpuinfo') ? substr_count(file_get_contents('/proc/cpuinfo'), 'processor') : 0; + } else { + $width = 0; + } + } else { + $width = 0; + } + } + return $width; + } + + /** + * Get number of concurrent preview generations from system config + * + * Two config entries, `preview_concurrency_new` and `preview_concurrency_all`, + * are available. If not set, the default values are determined with the hardware concurrency + * of the host. In case the hardware concurrency cannot be determined, or the user sets an + * invalid value, fallback values are: + * For new images whose previews do not exist and need to be generated, 4; + * For all preview generation requests, 8. + * Value of `preview_concurrency_all` should be greater than or equal to that of + * `preview_concurrency_new`, otherwise, the latter is returned. + * + * @param string $type either `preview_concurrency_new` or `preview_concurrency_all` + * @return int number of concurrent preview generations, or -1 if $type is invalid + */ + public function getNumConcurrentPreviews(string $type): int { + static $cached = []; + if (array_key_exists($type, $cached)) { + return $cached[$type]; + } + + $hardwareConcurrency = self::getHardwareConcurrency(); + switch ($type) { + case 'preview_concurrency_all': + $fallback = $hardwareConcurrency > 0 ? $hardwareConcurrency * 2 : 8; + $concurrency_all = $this->config->getSystemValueInt($type, $fallback); + $concurrency_new = $this->getNumConcurrentPreviews('preview_concurrency_new'); + $cached[$type] = max($concurrency_all, $concurrency_new); + break; + case 'preview_concurrency_new': + $fallback = $hardwareConcurrency > 0 ? $hardwareConcurrency : 4; + $cached[$type] = $this->config->getSystemValueInt($type, $fallback); + break; + default: + return -1; + } + return $cached[$type]; + } + + /** + * @param ISimpleFolder $previewFolder + * @param ISimpleFile[] $previewFiles + * @param File $file + * @param string $mimeType + * @param string $prefix + * @return ISimpleFile + * @throws NotFoundException + */ + private function getMaxPreview(ISimpleFolder $previewFolder, array $previewFiles, File $file, $mimeType, $prefix) { + // 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; + } + } + + $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); + } + + private function generateProviderPreview(ISimpleFolder $previewFolder, File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, string $prefix) { + $previewProviders = $this->previewManager->getProviders(); + foreach ($previewProviders as $supportedMimeType => $providers) { + // Filter out providers that does not support this mime + if (!preg_match($supportedMimeType, $mimeType)) { + continue; + } + + foreach ($providers as $providerClosure) { + $provider = $this->helper->getProvider($providerClosure); + if (!($provider instanceof IProviderV2)) { + continue; + } + + if (!$provider->isAvailable($file)) { + continue; + } + + $previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new'); + $sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency); + try { + $this->logger->debug('Calling preview provider for {mimeType} with width={width}, height={height}', [ + 'mimeType' => $mimeType, + 'width' => $width, + 'height' => $height, + ]); + $preview = $this->helper->getThumbnail($provider, $file, $width, $height); + } finally { + self::unguardWithSemaphore($sem); + } + + if (!($preview instanceof IImage)) { + 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) { + throw new NotFoundException(); + } + + return $file; + } + } + + throw new NotFoundException('No provider successfully handled the preview generation'); + } + + /** + * @param ISimpleFile $file + * @param string $prefix + * @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) { + /* + * If we are not cropping we have to make sure the requested image + * respects the aspect ratio of the original. + */ + if (!$crop) { + $ratio = $maxHeight / $maxWidth; + + if ($width === -1) { + $width = $height / $ratio; + } + if ($height === -1) { + $height = $width * $ratio; + } + + $ratioH = $height / $maxHeight; + $ratioW = $width / $maxWidth; + + /* + * Fill means that the $height and $width are the max + * Cover means min. + */ + if ($mode === IPreview::MODE_FILL) { + if ($ratioH > $ratioW) { + $height = $width * $ratio; + } else { + $width = $height / $ratio; + } + } elseif ($mode === IPreview::MODE_COVER) { + if ($ratioH > $ratioW) { + $width = $height / $ratio; + } else { + $height = $width * $ratio; + } + } + } + + if ($height !== $maxHeight && $width !== $maxWidth) { + /* + * Scale to the nearest power of four + */ + $pow4height = 4 ** ceil(log($height) / log(4)); + $pow4width = 4 ** ceil(log($width) / log(4)); + + // Minimum size is 64 + $pow4height = max($pow4height, 64); + $pow4width = max($pow4width, 64); + + $ratioH = $height / $pow4height; + $ratioW = $width / $pow4width; + + if ($ratioH < $ratioW) { + $width = $pow4width; + $height /= $ratioW; + } else { + $height = $pow4height; + $width /= $ratioH; + } + } + + /* + * Make sure the requested height and width fall within the max + * of the preview. + */ + if ($height > $maxHeight) { + $ratio = $height / $maxHeight; + $height = $maxHeight; + $width /= $ratio; + } + if ($width > $maxWidth) { + $ratio = $width / $maxWidth; + $width = $maxWidth; + $height /= $ratio; + } + + return [(int)round($width), (int)round($height)]; + } + + /** + * @throws NotFoundException + * @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid) + */ + private function generatePreview( + ISimpleFolder $previewFolder, + IImage $maxPreview, + int $width, + int $height, + bool $crop, + int $maxWidth, + int $maxHeight, + string $prefix, + bool $cacheResult, + ): ISimpleFile { + $preview = $maxPreview; + if (!$preview->valid()) { + throw new \InvalidArgumentException('Failed to generate preview, failed to load image'); + } + + $previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new'); + $sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency); + try { + if ($crop) { + if ($height !== $preview->height() && $width !== $preview->width()) { + //Resize + $widthR = $preview->width() / $width; + $heightR = $preview->height() / $height; + + if ($widthR > $heightR) { + $scaleH = $height; + $scaleW = $maxWidth / $heightR; + } else { + $scaleH = $maxHeight / $widthR; + $scaleW = $width; + } + $preview = $preview->preciseResizeCopy((int)round($scaleW), (int)round($scaleH)); + } + $cropX = (int)floor(abs($width - $preview->width()) * 0.5); + $cropY = (int)floor(abs($height - $preview->height()) * 0.5); + $preview = $preview->cropCopy($cropX, $cropY, $width, $height); + } else { + $preview = $maxPreview->resizeCopy(max($width, $height)); + } + } finally { + 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(); + } + 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 + */ + 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(); + + 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 . '"'); + } + } +} diff --git a/lib/private/Preview/GeneratorHelper.php b/lib/private/Preview/GeneratorHelper.php new file mode 100644 index 00000000000..e914dcc2002 --- /dev/null +++ b/lib/private/Preview/GeneratorHelper.php @@ -0,0 +1,69 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\IConfig; +use OCP\IImage; +use OCP\Image as OCPImage; +use OCP\Preview\IProvider; +use OCP\Preview\IProviderV2; + +/** + * Very small wrapper class to make the generator fully unit testable + */ +class GeneratorHelper { + /** @var IRootFolder */ + private $rootFolder; + + /** @var IConfig */ + private $config; + + public function __construct(IRootFolder $rootFolder, IConfig $config) { + $this->rootFolder = $rootFolder; + $this->config = $config; + } + + /** + * @param IProviderV2 $provider + * @param File $file + * @param int $maxWidth + * @param int $maxHeight + * + * @return bool|IImage + */ + public function getThumbnail(IProviderV2 $provider, File $file, $maxWidth, $maxHeight, bool $crop = false) { + if ($provider instanceof Imaginary) { + return $provider->getCroppedThumbnail($file, $maxWidth, $maxHeight, $crop) ?? false; + } + return $provider->getThumbnail($file, $maxWidth, $maxHeight) ?? false; + } + + /** + * @param ISimpleFile $maxPreview + * @return IImage + */ + public function getImage(ISimpleFile $maxPreview) { + $image = new OCPImage(); + $image->loadFromData($maxPreview->getContent()); + return $image; + } + + /** + * @param callable $providerClosure + * @return IProviderV2 + */ + public function getProvider($providerClosure) { + $provider = $providerClosure(); + if ($provider instanceof IProvider) { + $provider = new ProviderV1Adapter($provider); + } + return $provider; + } +} diff --git a/lib/private/Preview/HEIC.php b/lib/private/Preview/HEIC.php new file mode 100644 index 00000000000..64eb48e58df --- /dev/null +++ b/lib/private/Preview/HEIC.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2018 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IImage; +use OCP\Server; +use Psr\Log\LoggerInterface; + +/** + * Creates a JPG preview using ImageMagick via the PECL extension + * + * @package OC\Preview + */ +class HEIC extends ProviderV2 { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/(x-)?hei(f|c)/'; + } + + /** + * {@inheritDoc} + */ + public function isAvailable(FileInfo $file): bool { + return in_array('HEIC', \Imagick::queryFormats('HEI*')); + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + if (!$this->isAvailable($file)) { + return null; + } + + $tmpPath = $this->getLocalFile($file); + if ($tmpPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + // Creates \Imagick object from the heic file + try { + $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY); + $bp->setFormat('jpg'); + } catch (\Exception $e) { + \OC::$server->get(LoggerInterface::class)->error( + 'File: ' . $file->getPath() . ' Imagick says:', + [ + 'exception' => $e, + 'app' => 'core', + ] + ); + return null; + } + + $this->cleanTmpFiles(); + + //new bitmap image object + $image = new \OCP\Image(); + $image->loadFromData((string)$bp); + //check if image object is valid + return $image->valid() ? $image : null; + } + + /** + * Returns a preview of maxX times maxY dimensions in JPG format + * + * * The default resolution is already 72dpi, no need to change it for a bitmap output + * * It's possible to have proper colour conversion using profileimage(). + * ICC profiles are here: http://www.color.org/srgbprofiles.xalter + * * It's possible to Gamma-correct an image via gammaImage() + * + * @param string $tmpPath the location of the file to convert + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + * + * @throws \Exception + */ + private function getResizedPreview($tmpPath, $maxX, $maxY) { + $bp = new \Imagick(); + + // Some HEIC files just contain (or at least are identified as) other formats + // like JPEG. We just need to check if the image is safe to process. + $bp->pingImage($tmpPath . '[0]'); + $mimeType = $bp->getImageMimeType(); + if (!preg_match('/^image\/(x-)?(png|jpeg|gif|bmp|tiff|webp|hei(f|c)|avif)$/', $mimeType)) { + throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); + } + + // Layer 0 contains either the bitmap or a flat representation of all vector layers + $bp->readImage($tmpPath . '[0]'); + + // Fix orientation from EXIF + $bp->autoOrient(); + + $bp->setImageFormat('jpg'); + + $bp = $this->resize($bp, $maxX, $maxY); + + return $bp; + } + + /** + * Returns a resized \Imagick object + * + * If you want to know more on the various methods available to resize an + * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im + * + * @param \Imagick $bp + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + */ + private function resize($bp, $maxX, $maxY) { + [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry()); + + // We only need to resize a preview which doesn't fit in the maximum dimensions + if ($previewWidth > $maxX || $previewHeight > $maxY) { + // If we want a small image (thumbnail) let's be most space- and time-efficient + if ($maxX <= 500 && $maxY <= 500) { + $bp->thumbnailImage($maxY, $maxX, true); + $bp->stripImage(); + } else { + // A bigger image calls for some better resizing algorithm + // According to http://www.imagemagick.org/Usage/filter/#lanczos + // the catrom filter is almost identical to Lanczos2, but according + // to https://www.php.net/manual/en/imagick.resizeimage.php it is + // significantly faster + $bp->resizeImage($maxX, $maxY, \Imagick::FILTER_CATROM, 1, true); + } + } + + return $bp; + } +} diff --git a/lib/private/Preview/IMagickSupport.php b/lib/private/Preview/IMagickSupport.php new file mode 100644 index 00000000000..8225b1e7644 --- /dev/null +++ b/lib/private/Preview/IMagickSupport.php @@ -0,0 +1,44 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OCP\ICache; +use OCP\ICacheFactory; + +class IMagickSupport { + private ICache $cache; + private ?\Imagick $imagick; + + public function __construct(ICacheFactory $cacheFactory) { + $this->cache = $cacheFactory->createLocal('imagick'); + + if (extension_loaded('imagick')) { + $this->imagick = new \Imagick(); + } else { + $this->imagick = null; + } + } + + public function hasExtension(): bool { + return !is_null($this->imagick); + } + + public function supportsFormat(string $format): bool { + if (is_null($this->imagick)) { + return false; + } + + $cached = $this->cache->get($format); + if (!is_null($cached)) { + return $cached; + } + + $formatSupported = count($this->imagick->queryFormats($format)) === 1; + $this->cache->set($format, $cached); + return $formatSupported; + } +} diff --git a/lib/private/Preview/Illustrator.php b/lib/private/Preview/Illustrator.php new file mode 100644 index 00000000000..bff556a3177 --- /dev/null +++ b/lib/private/Preview/Illustrator.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.ai +class Illustrator extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/illustrator/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/application\/(illustrator|pdf)/'; + } +} diff --git a/lib/private/Preview/Image.php b/lib/private/Preview/Image.php new file mode 100644 index 00000000000..78a402c636a --- /dev/null +++ b/lib/private/Preview/Image.php @@ -0,0 +1,50 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\IImage; +use OCP\Server; +use Psr\Log\LoggerInterface; + +abstract class Image extends ProviderV2 { + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $maxSizeForImages = \OC::$server->getConfig()->getSystemValueInt('preview_max_filesize_image', 50); + $size = $file->getSize(); + + if ($maxSizeForImages !== -1 && $size > ($maxSizeForImages * 1024 * 1024)) { + return null; + } + + $image = new \OCP\Image(); + + $fileName = $this->getLocalFile($file); + if ($fileName === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + $image->loadFromFile($fileName); + $image->fixOrientation(); + + $this->cleanTmpFiles(); + + if ($image->valid()) { + $image->scaleDownToFit($maxX, $maxY); + + return $image; + } + return null; + } +} diff --git a/lib/private/Preview/Imaginary.php b/lib/private/Preview/Imaginary.php new file mode 100644 index 00000000000..d421da74ac8 --- /dev/null +++ b/lib/private/Preview/Imaginary.php @@ -0,0 +1,191 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Preview; + +use OC\StreamImage; +use OCP\Files\File; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IImage; + +use OCP\Image; +use Psr\Log\LoggerInterface; + +class Imaginary extends ProviderV2 { + /** @var IConfig */ + private $config; + + /** @var IClientService */ + private $service; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(array $config) { + parent::__construct($config); + $this->config = \OC::$server->get(IConfig::class); + $this->service = \OC::$server->get(IClientService::class); + $this->logger = \OC::$server->get(LoggerInterface::class); + } + + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return self::supportedMimeTypes(); + } + + public static function supportedMimeTypes(): string { + return '/(image\/(bmp|x-bitmap|png|jpeg|gif|heic|heif|svg\+xml|tiff|webp)|application\/illustrator)/'; + } + + public function getCroppedThumbnail(File $file, int $maxX, int $maxY, bool $crop): ?IImage { + $maxSizeForImages = $this->config->getSystemValueInt('preview_max_filesize_image', 50); + + $size = $file->getSize(); + + if ($maxSizeForImages !== -1 && $size > ($maxSizeForImages * 1024 * 1024)) { + return null; + } + + $imaginaryUrl = $this->config->getSystemValueString('preview_imaginary_url', 'invalid'); + if ($imaginaryUrl === 'invalid') { + $this->logger->error('Imaginary preview provider is enabled, but no url is configured. Please provide the url of your imaginary server to the \'preview_imaginary_url\' config variable.'); + return null; + } + $imaginaryUrl = rtrim($imaginaryUrl, '/'); + + // Object store + $stream = $file->fopen('r'); + if (!$stream || !is_resource($stream) || feof($stream)) { + return null; + } + + $httpClient = $this->service->newClient(); + + $convert = false; + $autorotate = true; + + switch ($file->getMimeType()) { + case 'image/heic': + // Autorotate seems to be broken for Heic so disable for that + $autorotate = false; + $mimeType = 'jpeg'; + break; + case 'image/gif': + case 'image/png': + $mimeType = 'png'; + break; + case 'image/svg+xml': + case 'application/pdf': + case 'application/illustrator': + $convert = true; + // Converted files do not need to be autorotated + $autorotate = false; + $mimeType = 'png'; + break; + default: + $mimeType = 'jpeg'; + } + + $preview_format = $this->config->getSystemValueString('preview_format', 'jpeg'); + + switch ($preview_format) { // Change the format to the correct one + case 'webp': + $mimeType = 'webp'; + break; + default: + } + + $operations = []; + + if ($convert) { + $operations[] = [ + 'operation' => 'convert', + 'params' => [ + 'type' => $mimeType, + ] + ]; + } elseif ($autorotate) { + $operations[] = [ + 'operation' => 'autorotate', + ]; + } + + switch ($mimeType) { + case 'jpeg': + $quality = $this->config->getAppValue('preview', 'jpeg_quality', '80'); + break; + case 'webp': + $quality = $this->config->getAppValue('preview', 'webp_quality', '80'); + break; + default: + $quality = $this->config->getAppValue('preview', 'jpeg_quality', '80'); + } + + $operations[] = [ + 'operation' => ($crop ? 'smartcrop' : 'fit'), + 'params' => [ + 'width' => $maxX, + 'height' => $maxY, + 'stripmeta' => 'true', + 'type' => $mimeType, + 'norotation' => 'true', + 'quality' => $quality, + ] + ]; + + try { + $imaginaryKey = $this->config->getSystemValueString('preview_imaginary_key', ''); + $response = $httpClient->post( + $imaginaryUrl . '/pipeline', [ + 'query' => ['operations' => json_encode($operations), 'key' => $imaginaryKey], + 'stream' => true, + 'content-type' => $file->getMimeType(), + 'body' => $stream, + 'nextcloud' => ['allow_local_address' => true], + 'timeout' => 120, + 'connect_timeout' => 3, + ]); + } catch (\Throwable $e) { + $this->logger->info('Imaginary preview generation failed: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + return null; + } + + if ($response->getStatusCode() !== 200) { + $this->logger->info('Imaginary preview generation failed: ' . json_decode($response->getBody())['message']); + return null; + } + + // This is not optimal but previews are distorted if the wrong width and height values are + // used. Both dimension headers are only sent when passing the option "-return-size" to + // Imaginary. + if ($response->getHeader('Image-Width') && $response->getHeader('Image-Height')) { + $image = new StreamImage( + $response->getBody(), + $response->getHeader('Content-Type'), + (int)$response->getHeader('Image-Width'), + (int)$response->getHeader('Image-Height'), + ); + } else { + $image = new Image(); + $image->loadFromFileHandle($response->getBody()); + } + + return $image->valid() ? $image : null; + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + return $this->getCroppedThumbnail($file, $maxX, $maxY, false); + } +} diff --git a/lib/private/Preview/ImaginaryPDF.php b/lib/private/Preview/ImaginaryPDF.php new file mode 100644 index 00000000000..d26c6d1b7ff --- /dev/null +++ b/lib/private/Preview/ImaginaryPDF.php @@ -0,0 +1,14 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Preview; + +class ImaginaryPDF extends Imaginary { + public static function supportedMimeTypes(): string { + return '/application\/pdf/'; + } +} diff --git a/lib/private/Preview/JPEG.php b/lib/private/Preview/JPEG.php new file mode 100644 index 00000000000..a1a394f81c9 --- /dev/null +++ b/lib/private/Preview/JPEG.php @@ -0,0 +1,17 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +class JPEG extends Image { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/jpeg/'; + } +} diff --git a/lib/private/Preview/Krita.php b/lib/private/Preview/Krita.php new file mode 100644 index 00000000000..e96fac993aa --- /dev/null +++ b/lib/private/Preview/Krita.php @@ -0,0 +1,35 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\IImage; + +class Krita extends Bundled { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/x-krita/'; + } + + + /** + * @inheritDoc + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $image = $this->extractThumbnail($file, 'mergedimage.png'); + if (($image !== null) && $image->valid()) { + return $image; + } + $image = $this->extractThumbnail($file, 'preview.png'); + if (($image !== null) && $image->valid()) { + return $image; + } + return null; + } +} diff --git a/lib/private/Preview/MP3.php b/lib/private/Preview/MP3.php new file mode 100644 index 00000000000..add0028738e --- /dev/null +++ b/lib/private/Preview/MP3.php @@ -0,0 +1,65 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\IImage; +use OCP\Server; +use Psr\Log\LoggerInterface; +use wapmorgan\Mp3Info\Mp3Info; +use function OCP\Log\logger; + +class MP3 extends ProviderV2 { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/audio\/mpeg/'; + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $tmpPath = $this->getLocalFile($file); + if ($tmpPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + try { + $audio = new Mp3Info($tmpPath, true); + /** @var string|null|false $picture */ + $picture = $audio->getCover(); + } catch (\Throwable $e) { + logger('core')->info('Error while getting cover from mp3 file: ' . $e->getMessage(), [ + 'fileId' => $file->getId(), + 'filePath' => $file->getPath(), + ]); + return null; + } finally { + $this->cleanTmpFiles(); + } + + if (is_string($picture)) { + $image = new \OCP\Image(); + $image->loadFromData($picture); + + if ($image->valid()) { + $image->scaleDownToFit($maxX, $maxY); + + return $image; + } + } + + return null; + } +} diff --git a/lib/private/Preview/MSOffice2003.php b/lib/private/Preview/MSOffice2003.php new file mode 100644 index 00000000000..a52e618d484 --- /dev/null +++ b/lib/private/Preview/MSOffice2003.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.docm, .dotm, .xls(m), .xlt(m), .xla(m), .ppt(m), .pot(m), .pps(m), .ppa(m) +class MSOffice2003 extends Office { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/vnd.ms-.*/'; + } +} diff --git a/lib/private/Preview/MSOffice2007.php b/lib/private/Preview/MSOffice2007.php new file mode 100644 index 00000000000..317f2dcc7f1 --- /dev/null +++ b/lib/private/Preview/MSOffice2007.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.docx, .dotx, .xlsx, .xltx, .pptx, .potx, .ppsx +class MSOffice2007 extends Office { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/vnd.openxmlformats-officedocument.*/'; + } +} diff --git a/lib/private/Preview/MSOfficeDoc.php b/lib/private/Preview/MSOfficeDoc.php new file mode 100644 index 00000000000..2e1044395f1 --- /dev/null +++ b/lib/private/Preview/MSOfficeDoc.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.doc, .dot +class MSOfficeDoc extends Office { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/msword/'; + } +} diff --git a/lib/private/Preview/MarkDown.php b/lib/private/Preview/MarkDown.php new file mode 100644 index 00000000000..c20433a1ac0 --- /dev/null +++ b/lib/private/Preview/MarkDown.php @@ -0,0 +1,127 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\IImage; + +class MarkDown extends TXT { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/text\/(x-)?markdown/'; + } + + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $content = $file->fopen('r'); + + if ($content === false) { + return null; + } + + $content = stream_get_contents($content, 3000); + + //don't create previews of empty text files + if (trim($content) === '') { + return null; + } + + // Merge text paragraph lines that might belong together + $content = preg_replace('/^(\s*)\*\s/mU', '$1- ', $content); + + $content = preg_replace('/((?!^(\s*-|#)).*)(\w|\\|\.)(\r\n|\n|\r)(\w|\*)/mU', '$1 $3', $content); + + // Remove markdown symbols that we cannot easily represent in rendered text in the preview + $content = preg_replace('/\*\*(.*)\*\*/U', '$1', $content); + $content = preg_replace('/\*(.*)\*/U', '$1', $content); + $content = preg_replace('/\_\_(.*)\_\_/U', '$1', $content); + $content = preg_replace('/\_(.*)\_/U', '$1', $content); + $content = preg_replace('/\~\~(.*)\~\~/U', '$1', $content); + + $content = preg_replace('/\!?\[((.|\n)*)\]\((.*)\)/mU', '$1 ($3)', $content); + $content = preg_replace('/\n\n+/', "\n", $content); + + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); + + $lines = preg_split("/\r\n|\n|\r/", $content); + + // Define text size of text file preview + $fontSize = $maxX ? (int)((1 / ($maxX >= 512 ? 60 : 40) * $maxX)) : 10; + + $image = imagecreate($maxX, $maxY); + imagecolorallocate($image, 255, 255, 255); + $textColor = imagecolorallocate($image, 0, 0, 0); + + $fontFile = __DIR__ . '/../../../core/fonts/NotoSans-Regular.ttf'; + $fontFileBold = __DIR__ . '/../../../core/fonts/NotoSans-Bold.ttf'; + + $canUseTTF = function_exists('imagettftext'); + + $textOffset = (int)min($maxX * 0.05, $maxY * 0.05); + $nextLineStart = 0; + $y = $textOffset; + foreach ($lines as $line) { + $actualFontSize = $fontSize; + if (mb_strpos($line, '# ') === 0) { + $actualFontSize *= 2; + } + if (mb_strpos($line, '## ') === 0) { + $actualFontSize *= 1.8; + } + if (mb_strpos($line, '### ') === 0) { + $actualFontSize *= 1.6; + } + if (mb_strpos($line, '#### ') === 0) { + $actualFontSize *= 1.4; + } + if (mb_strpos($line, '##### ') === 0) { + $actualFontSize *= 1.2; + } + if (mb_strpos($line, '###### ') === 0) { + $actualFontSize *= 1.1; + } + + // Add spacing before headlines + if ($actualFontSize !== $fontSize && $y !== $textOffset) { + $y += (int)($actualFontSize * 2); + } + + $x = $textOffset; + $y += (int)($nextLineStart + $actualFontSize); + + if ($canUseTTF === true) { + $wordWrap = (int)((1 / $actualFontSize * 1.3) * $maxX); + + // Get rid of markdown symbols that we still needed for the font size + $line = preg_replace('/^#*\s/', '', $line); + + $wrappedText = wordwrap($line, $wordWrap, "\n"); + $linesWrapped = count(explode("\n", $wrappedText)); + imagettftext($image, $actualFontSize, 0, $x, $y, $textColor, $actualFontSize === $fontSize ? $fontFile : $fontFileBold, $wrappedText); + $nextLineStart = (int)($linesWrapped * ceil($actualFontSize * 2)); + if ($actualFontSize !== $fontSize && $y !== $textOffset) { + $nextLineStart -= $actualFontSize; + } + } else { + $y -= $fontSize; + imagestring($image, 1, $x, $y, $line, $textColor); + $nextLineStart = $fontSize; + } + + if ($y >= $maxY) { + break; + } + } + + $imageObject = new \OCP\Image(); + $imageObject->setResource($image); + + return $imageObject->valid() ? $imageObject : null; + } +} diff --git a/lib/private/Preview/MimeIconProvider.php b/lib/private/Preview/MimeIconProvider.php new file mode 100644 index 00000000000..d1963fe882b --- /dev/null +++ b/lib/private/Preview/MimeIconProvider.php @@ -0,0 +1,83 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OCA\Theming\ThemingDefaults; +use OCP\App\IAppManager; +use OCP\Files\IMimeTypeDetector; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\Preview\IMimeIconProvider; + +class MimeIconProvider implements IMimeIconProvider { + public function __construct( + protected IMimeTypeDetector $mimetypeDetector, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IAppManager $appManager, + protected ThemingDefaults $themingDefaults, + ) { + } + + public function getMimeIconUrl(string $mime): ?string { + if (!$mime) { + return null; + } + + // Fetch all the aliases + $aliases = $this->mimetypeDetector->getAllAliases(); + + // Remove comments + $aliases = array_filter($aliases, static function (string $key) { + return !($key === '' || $key[0] === '_'); + }, ARRAY_FILTER_USE_KEY); + + // Map all the aliases recursively + foreach ($aliases as $alias => $value) { + if ($alias === $mime) { + $mime = $value; + } + } + + $fileName = str_replace('/', '-', $mime); + if ($url = $this->searchfileName($fileName)) { + return $url; + } + + $mimeType = explode('/', $mime)[0]; + if ($url = $this->searchfileName($mimeType)) { + return $url; + } + + return null; + } + + private function searchfileName(string $fileName): ?string { + // If the file exists in the current enabled legacy + // custom theme, let's return it + $theme = $this->config->getSystemValue('theme', ''); + if (!empty($theme)) { + $path = "/themes/$theme/core/img/filetypes/$fileName.svg"; + if (file_exists(\OC::$SERVERROOT . $path)) { + return $this->urlGenerator->getAbsoluteURL($path); + } + } + + // Previously, we used to pass this through Theming + // But it was only used to colour icons containing + // 0082c9. Since with vue we moved to inline svg icons, + // we can just use the default core icons. + + // Finally, if the file exists in core, let's return it + $path = "/core/img/filetypes/$fileName.svg"; + if (file_exists(\OC::$SERVERROOT . $path)) { + return $this->urlGenerator->getAbsoluteURL($path); + } + + return null; + } +} diff --git a/lib/private/Preview/Movie.php b/lib/private/Preview/Movie.php new file mode 100644 index 00000000000..47895f999d8 --- /dev/null +++ b/lib/private/Preview/Movie.php @@ -0,0 +1,194 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OC\Preview; + +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IConfig; +use OCP\IImage; +use OCP\ITempManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class Movie extends ProviderV2 { + private IConfig $config; + + private ?string $binary = null; + + public function __construct(array $options = []) { + parent::__construct($options); + $this->config = Server::get(IConfig::class); + } + + public function getMimeType(): string { + return '/video\/.*/'; + } + + /** + * {@inheritDoc} + */ + public function isAvailable(FileInfo $file): bool { + if (is_null($this->binary)) { + if (isset($this->options['movieBinary'])) { + $this->binary = $this->options['movieBinary']; + } + } + return is_string($this->binary); + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + // TODO: use proc_open() and stream the source file ? + + if (!$this->isAvailable($file)) { + return null; + } + + $result = null; + if ($this->useTempFile($file)) { + // Try downloading 5 MB first, as it's likely that the first frames are present there. + // In some cases this doesn't work, for example when the moov atom is at the + // end of the file, so if it fails we fall back to getting the full file. + // Unless the file is not local (e.g. S3) as we do not want to download the whole (e.g. 37Gb) file + if ($file->getStorage()->isLocal()) { + $sizeAttempts = [5242880, null]; + } else { + $sizeAttempts = [5242880]; + } + } else { + // size is irrelevant, only attempt once + $sizeAttempts = [null]; + } + + foreach ($sizeAttempts as $size) { + $absPath = $this->getLocalFile($file, $size); + if ($absPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + $result = $this->generateThumbNail($maxX, $maxY, $absPath, 5); + if ($result === null) { + $result = $this->generateThumbNail($maxX, $maxY, $absPath, 1); + if ($result === null) { + $result = $this->generateThumbNail($maxX, $maxY, $absPath, 0); + } + } + + $this->cleanTmpFiles(); + + if ($result !== null) { + break; + } + } + + return $result; + } + + private function useHdr(string $absPath): bool { + // load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path + $ffprobe_binary = $this->config->getSystemValue('preview_ffprobe_path', null) ?? (pathinfo($this->binary, PATHINFO_DIRNAME) . '/ffprobe'); + // run ffprobe on the video file to get value of "color_transfer" + $test_hdr_cmd = [$ffprobe_binary,'-select_streams', 'v:0', + '-show_entries', 'stream=color_transfer', + '-of', 'default=noprint_wrappers=1:nokey=1', + $absPath]; + $test_hdr_proc = proc_open($test_hdr_cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $test_hdr_pipes); + if ($test_hdr_proc === false) { + return false; + } + $test_hdr_stdout = trim(stream_get_contents($test_hdr_pipes[1])); + $test_hdr_stderr = trim(stream_get_contents($test_hdr_pipes[2])); + proc_close($test_hdr_proc); + // search build options for libzimg (provides zscale filter) + $ffmpeg_libzimg_installed = strpos($test_hdr_stderr, '--enable-libzimg'); + // Only values of "smpte2084" and "arib-std-b67" indicate an HDR video. + // Only return true if video is detected as HDR and libzimg is installed. + if (($test_hdr_stdout === 'smpte2084' || $test_hdr_stdout === 'arib-std-b67') && $ffmpeg_libzimg_installed !== false) { + return true; + } else { + return false; + } + } + + private function generateThumbNail(int $maxX, int $maxY, string $absPath, int $second): ?IImage { + $tmpPath = Server::get(ITempManager::class)->getTemporaryFile(); + + if ($tmpPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $absPath, + ['app' => 'core'] + ); + return null; + } + + $binaryType = substr(strrchr($this->binary, '/'), 1); + + if ($binaryType === 'avconv') { + $cmd = [$this->binary, '-y', '-ss', (string)$second, + '-i', $absPath, + '-an', '-f', 'mjpeg', '-vframes', '1', '-vsync', '1', + $tmpPath]; + } elseif ($binaryType === 'ffmpeg') { + if ($this->useHdr($absPath)) { + // Force colorspace to '2020_ncl' because some videos are + // tagged incorrectly as 'reserved' resulting in fail if not forced. + $cmd = [$this->binary, '-y', '-ss', (string)$second, + '-i', $absPath, + '-f', 'mjpeg', '-vframes', '1', + '-vf', 'zscale=min=2020_ncl:t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p', + $tmpPath]; + } else { + // always default to generating preview using non-HDR command + $cmd = [$this->binary, '-y', '-ss', (string)$second, + '-i', $absPath, + '-f', 'mjpeg', '-vframes', '1', + $tmpPath]; + } + } else { + // Not supported + unlink($tmpPath); + return null; + } + + $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $returnCode = -1; + $output = ''; + if (is_resource($proc)) { + $stderr = trim(stream_get_contents($pipes[2])); + $stdout = trim(stream_get_contents($pipes[1])); + $returnCode = proc_close($proc); + $output = $stdout . $stderr; + } + + if ($returnCode === 0) { + $image = new \OCP\Image(); + $image->loadFromFile($tmpPath); + if ($image->valid()) { + unlink($tmpPath); + $image->scaleDownToFit($maxX, $maxY); + + return $image; + } + } + + if ($second === 0) { + $logger = Server::get(LoggerInterface::class); + $logger->info('Movie preview generation failed Output: {output}', ['app' => 'core', 'output' => $output]); + } + + unlink($tmpPath); + return null; + } +} diff --git a/lib/private/Preview/Office.php b/lib/private/Preview/Office.php new file mode 100644 index 00000000000..ffba0211de2 --- /dev/null +++ b/lib/private/Preview/Office.php @@ -0,0 +1,96 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IImage; +use OCP\ITempManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +abstract class Office extends ProviderV2 { + /** + * {@inheritDoc} + */ + public function isAvailable(FileInfo $file): bool { + return is_string($this->options['officeBinary']); + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + if (!$this->isAvailable($file)) { + return null; + } + + $tempManager = Server::get(ITempManager::class); + + // The file to generate the preview for. + $absPath = $this->getLocalFile($file); + if ($absPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + // The destination for the LibreOffice user profile. + // LibreOffice can rune once per user profile and therefore instance id and file id are included. + $profile = $tempManager->getTemporaryFolder( + 'nextcloud-office-profile-' . \OC_Util::getInstanceId() . '-' . $file->getId() + ); + + // The destination for the LibreOffice convert result. + $outdir = $tempManager->getTemporaryFolder( + 'nextcloud-office-preview-' . \OC_Util::getInstanceId() . '-' . $file->getId() + ); + + if ($profile === false || $outdir === false) { + $this->cleanTmpFiles(); + return null; + } + + $parameters = [ + $this->options['officeBinary'], + '-env:UserInstallation=file://' . escapeshellarg($profile), + '--headless', + '--nologo', + '--nofirststartwizard', + '--invisible', + '--norestore', + '--convert-to png', + '--outdir ' . escapeshellarg($outdir), + escapeshellarg($absPath), + ]; + + $cmd = implode(' ', $parameters); + exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + $this->cleanTmpFiles(); + return null; + } + + $preview = $outdir . pathinfo($absPath, PATHINFO_FILENAME) . '.png'; + + $image = new \OCP\Image(); + $image->loadFromFile($preview); + + $this->cleanTmpFiles(); + + if ($image->valid()) { + $image->scaleDownToFit($maxX, $maxY); + return $image; + } + + return null; + } +} diff --git a/lib/private/Preview/OpenDocument.php b/lib/private/Preview/OpenDocument.php new file mode 100644 index 00000000000..f590eb6a59c --- /dev/null +++ b/lib/private/Preview/OpenDocument.php @@ -0,0 +1,33 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.odt, .ott, .oth, .odm, .odg, .otg, .odp, .otp, .ods, .ots, .odc, .odf, .odb, .odi, .oxt +use OCP\Files\File; +use OCP\IImage; + +class OpenDocument extends Bundled { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/vnd.oasis.opendocument.*/'; + } + + + /** + * @inheritDoc + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $image = $this->extractThumbnail($file, 'Thumbnails/thumbnail.png'); + if (($image !== null) && $image->valid()) { + return $image; + } + return null; + } +} diff --git a/lib/private/Preview/PDF.php b/lib/private/Preview/PDF.php new file mode 100644 index 00000000000..9de14685925 --- /dev/null +++ b/lib/private/Preview/PDF.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.pdf +class PDF extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/pdf/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/application\/pdf/'; + } +} diff --git a/lib/private/Preview/PNG.php b/lib/private/Preview/PNG.php new file mode 100644 index 00000000000..49d0cd059ba --- /dev/null +++ b/lib/private/Preview/PNG.php @@ -0,0 +1,17 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +class PNG extends Image { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/png/'; + } +} diff --git a/lib/private/Preview/Photoshop.php b/lib/private/Preview/Photoshop.php new file mode 100644 index 00000000000..b7209120530 --- /dev/null +++ b/lib/private/Preview/Photoshop.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.psd +class Photoshop extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/x-photoshop/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/(application|image)\/(x-photoshop|x-psd)/'; + } +} diff --git a/lib/private/Preview/Postscript.php b/lib/private/Preview/Postscript.php new file mode 100644 index 00000000000..04c667926aa --- /dev/null +++ b/lib/private/Preview/Postscript.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.eps +class Postscript extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/postscript/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/application\/postscript/'; + } +} diff --git a/lib/private/Preview/Provider.php b/lib/private/Preview/Provider.php new file mode 100644 index 00000000000..26f0ac09f08 --- /dev/null +++ b/lib/private/Preview/Provider.php @@ -0,0 +1,50 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Preview\IProvider; + +abstract class Provider implements IProvider { + private $options; + + /** + * Constructor + * + * @param array $options + */ + public function __construct(array $options = []) { + $this->options = $options; + } + + /** + * @return string Regex with the mimetypes that are supported by this provider + */ + abstract public function getMimeType(); + + /** + * Check if a preview can be generated for $path + * + * @param \OCP\Files\FileInfo $file + * @return bool + */ + public function isAvailable(\OCP\Files\FileInfo $file) { + return true; + } + + /** + * Generates thumbnail which fits in $maxX and $maxY and keeps the aspect ratio, for file at path $path + * + * @param string $path Path of file + * @param int $maxX The maximum X size of the thumbnail. It can be smaller depending on the shape of the image + * @param int $maxY The maximum Y size of the thumbnail. It can be smaller depending on the shape of the image + * @param bool $scalingup Disable/Enable upscaling of previews + * @param \OC\Files\View $fileview fileview object of user folder + * @return bool|\OCP\IImage false if no preview was generated + */ + abstract public function getThumbnail($path, $maxX, $maxY, $scalingup, $fileview); +} diff --git a/lib/private/Preview/ProviderV1Adapter.php b/lib/private/Preview/ProviderV1Adapter.php new file mode 100644 index 00000000000..ba8826ef765 --- /dev/null +++ b/lib/private/Preview/ProviderV1Adapter.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OC\Files\View; +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IImage; +use OCP\Preview\IProvider; +use OCP\Preview\IProviderV2; + +class ProviderV1Adapter implements IProviderV2 { + private $providerV1; + + public function __construct(IProvider $providerV1) { + $this->providerV1 = $providerV1; + } + + public function getMimeType(): string { + return (string)$this->providerV1->getMimeType(); + } + + public function isAvailable(FileInfo $file): bool { + return (bool)$this->providerV1->isAvailable($file); + } + + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + [$view, $path] = $this->getViewAndPath($file); + $thumbnail = $this->providerV1->getThumbnail($path, $maxX, $maxY, false, $view); + return $thumbnail === false ? null: $thumbnail; + } + + private function getViewAndPath(File $file) { + $view = new View(dirname($file->getPath())); + $path = $file->getName(); + + return [$view, $path]; + } +} diff --git a/lib/private/Preview/ProviderV2.php b/lib/private/Preview/ProviderV2.php new file mode 100644 index 00000000000..556d1099d2d --- /dev/null +++ b/lib/private/Preview/ProviderV2.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Preview; + +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IImage; +use OCP\ITempManager; +use OCP\Preview\IProviderV2; +use OCP\Server; +use Psr\Log\LoggerInterface; + +abstract class ProviderV2 implements IProviderV2 { + protected array $tmpFiles = []; + + public function __construct( + protected array $options = [], + ) { + } + + /** + * @return string Regex with the mimetypes that are supported by this provider + */ + abstract public function getMimeType(): string ; + + /** + * Check if a preview can be generated for $path + * + * @param FileInfo $file + * @return bool + */ + public function isAvailable(FileInfo $file): bool { + return true; + } + + /** + * get thumbnail for file at path $path + * + * @param File $file + * @param int $maxX The maximum X size of the thumbnail. It can be smaller depending on the shape of the image + * @param int $maxY The maximum Y size of the thumbnail. It can be smaller depending on the shape of the image + * @return null|\OCP\IImage null if no preview was generated + * @since 17.0.0 + */ + abstract public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage; + + protected function useTempFile(File $file): bool { + return $file->isEncrypted() || !$file->getStorage()->isLocal(); + } + + /** + * Get a path to either the local file or temporary file + * + * @param File $file + * @param ?int $maxSize maximum size for temporary files + */ + protected function getLocalFile(File $file, ?int $maxSize = null): string|false { + if ($this->useTempFile($file)) { + $absPath = Server::get(ITempManager::class)->getTemporaryFile(); + + if ($absPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return false; + } + + $content = $file->fopen('r'); + if ($content === false) { + return false; + } + + if ($maxSize) { + $content = stream_get_contents($content, $maxSize); + } + + file_put_contents($absPath, $content); + $this->tmpFiles[] = $absPath; + return $absPath; + } else { + $path = $file->getStorage()->getLocalFile($file->getInternalPath()); + if (is_string($path)) { + return $path; + } else { + return false; + } + } + } + + /** + * Clean any generated temporary files + */ + protected function cleanTmpFiles(): void { + foreach ($this->tmpFiles as $tmpFile) { + unlink($tmpFile); + } + + $this->tmpFiles = []; + } +} diff --git a/lib/private/Preview/SGI.php b/lib/private/Preview/SGI.php new file mode 100644 index 00000000000..78b1ea5828a --- /dev/null +++ b/lib/private/Preview/SGI.php @@ -0,0 +1,24 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +//.sgi +class SGI extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/(x-)?sgi/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/image\/(x-)?sgi/'; + } +} diff --git a/lib/private/Preview/SVG.php b/lib/private/Preview/SVG.php new file mode 100644 index 00000000000..d9f7701f411 --- /dev/null +++ b/lib/private/Preview/SVG.php @@ -0,0 +1,70 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\IImage; +use Psr\Log\LoggerInterface; + +class SVG extends ProviderV2 { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/svg\+xml/'; + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + try { + $content = stream_get_contents($file->fopen('r')); + if (substr($content, 0, 5) !== '<?xml') { + $content = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' . $content; + } + + // Do not parse SVG files with references + if (preg_match('/["\s](xlink:)?href\s*=/i', $content)) { + return null; + } + + $svg = new \Imagick(); + + $svg->pingImageBlob($content); + $mimeType = $svg->getImageMimeType(); + if (!preg_match($this->getMimeType(), $mimeType)) { + throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); + } + + $svg->setBackgroundColor(new \ImagickPixel('transparent')); + $svg->readImageBlob($content); + $svg->setImageFormat('png32'); + } catch (\Exception $e) { + \OC::$server->get(LoggerInterface::class)->error( + 'File: ' . $file->getPath() . ' Imagick says:', + [ + 'exception' => $e, + 'app' => 'core', + ] + ); + return null; + } + + //new image object + $image = new \OCP\Image(); + $image->loadFromData((string)$svg); + //check if image object is valid + if ($image->valid()) { + $image->scaleDownToFit($maxX, $maxY); + + return $image; + } + return null; + } +} diff --git a/lib/private/Preview/StarOffice.php b/lib/private/Preview/StarOffice.php new file mode 100644 index 00000000000..9ea540dc912 --- /dev/null +++ b/lib/private/Preview/StarOffice.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.sxw, .stw, .sxc, .stc, .sxd, .std, .sxi, .sti, .sxg, .sxm +class StarOffice extends Office { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/vnd.sun.xml.*/'; + } +} diff --git a/lib/private/Preview/Storage/Root.php b/lib/private/Preview/Storage/Root.php new file mode 100644 index 00000000000..41378653962 --- /dev/null +++ b/lib/private/Preview/Storage/Root.php @@ -0,0 +1,74 @@ +<?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(); + } +} diff --git a/lib/private/Preview/TGA.php b/lib/private/Preview/TGA.php new file mode 100644 index 00000000000..675907b4e49 --- /dev/null +++ b/lib/private/Preview/TGA.php @@ -0,0 +1,24 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +//.tga +class TGA extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/(x-)?t(ar)?ga/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/image\/(x-)?t(ar)?ga/'; + } +} diff --git a/lib/private/Preview/TIFF.php b/lib/private/Preview/TIFF.php new file mode 100644 index 00000000000..cd81e611d0b --- /dev/null +++ b/lib/private/Preview/TIFF.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +//.tiff +class TIFF extends Bitmap { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/tiff/'; + } + + /** + * {@inheritDoc} + */ + protected function getAllowedMimeTypes(): string { + return '/image\/tiff/'; + } +} diff --git a/lib/private/Preview/TXT.php b/lib/private/Preview/TXT.php new file mode 100644 index 00000000000..1a1d64f3e08 --- /dev/null +++ b/lib/private/Preview/TXT.php @@ -0,0 +1,89 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IImage; + +class TXT extends ProviderV2 { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/text\/plain/'; + } + + /** + * {@inheritDoc} + */ + public function isAvailable(FileInfo $file): bool { + return $file->getSize() > 0; + } + + /** + * {@inheritDoc} + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + if (!$this->isAvailable($file)) { + return null; + } + + $content = $file->fopen('r'); + + if ($content === false) { + return null; + } + + $content = stream_get_contents($content, 3000); + + //don't create previews of empty text files + if (trim($content) === '') { + return null; + } + + $lines = preg_split("/\r\n|\n|\r/", $content); + + // Define text size of text file preview + $fontSize = $maxX ? (int)((1 / 32) * $maxX) : 5; //5px + $lineSize = ceil($fontSize * 1.5); + + $image = imagecreate($maxX, $maxY); + imagecolorallocate($image, 255, 255, 255); + $textColor = imagecolorallocate($image, 0, 0, 0); + + $fontFile = __DIR__; + $fontFile .= '/../../../core'; + $fontFile .= '/fonts/NotoSans-Regular.ttf'; + + $canUseTTF = function_exists('imagettftext'); + + foreach ($lines as $index => $line) { + $index = $index + 1; + + $x = 1; + $y = (int)($index * $lineSize); + + if ($canUseTTF === true) { + imagettftext($image, $fontSize, 0, $x, $y, $textColor, $fontFile, $line); + } else { + $y -= $fontSize; + imagestring($image, 1, $x, $y, $line, $textColor); + } + + if (($index * $lineSize) >= $maxY) { + break; + } + } + + $imageObject = new \OCP\Image(); + $imageObject->setResource($image); + + return $imageObject->valid() ? $imageObject : null; + } +} diff --git a/lib/private/Preview/Watcher.php b/lib/private/Preview/Watcher.php new file mode 100644 index 00000000000..21f040d8342 --- /dev/null +++ b/lib/private/Preview/Watcher.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OCP\Files\FileInfo; +use OCP\Files\Folder; +use OCP\Files\IAppData; +use OCP\Files\Node; +use OCP\Files\NotFoundException; + +/** + * Class Watcher + * + * @package OC\Preview + * + * 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 postWrite(Node $node) { + $this->deleteNode($node); + } + + protected function deleteNode(FileInfo $node) { + // We only handle files + if ($node instanceof Folder) { + return; + } + + try { + if (is_null($node->getId())) { + return; + } + $folder = $this->appData->getFolder((string)$node->getId()); + $folder->delete(); + } catch (NotFoundException $e) { + //Nothing to do + } + } + + public function versionRollback(array $data) { + if (isset($data['node'])) { + $this->deleteNode($data['node']); + } + } +} diff --git a/lib/private/Preview/WatcherConnector.php b/lib/private/Preview/WatcherConnector.php new file mode 100644 index 00000000000..c34dd1dde4d --- /dev/null +++ b/lib/private/Preview/WatcherConnector.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OC\SystemConfig; +use OCA\Files_Versions\Events\VersionRestoredEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; +use OCP\Files\Node; + +class WatcherConnector { + public function __construct( + private IRootFolder $root, + private SystemConfig $config, + private IEventDispatcher $dispatcher, + ) { + } + + private function getWatcher(): Watcher { + return \OCP\Server::get(Watcher::class); + } + + public function connectWatcher(): void { + // Do not connect if we are not setup yet! + if ($this->config->getValue('instanceid', null) !== null) { + $this->root->listen('\OC\Files', 'postWrite', function (Node $node) { + $this->getWatcher()->postWrite($node); + }); + + $this->dispatcher->addListener(VersionRestoredEvent::class, function (VersionRestoredEvent $event) { + $this->getWatcher()->versionRollback(['node' => $event->getVersion()->getSourceFile()]); + }); + } + } +} diff --git a/lib/private/Preview/WebP.php b/lib/private/Preview/WebP.php new file mode 100644 index 00000000000..25b922e9190 --- /dev/null +++ b/lib/private/Preview/WebP.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Preview; + +use OCP\Files\FileInfo; + +class WebP extends Image { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/webp/'; + } + + public function isAvailable(FileInfo $file): bool { + return (bool)(imagetypes() & IMG_WEBP); + } +} diff --git a/lib/private/Preview/XBitmap.php b/lib/private/Preview/XBitmap.php new file mode 100644 index 00000000000..c8337cc252d --- /dev/null +++ b/lib/private/Preview/XBitmap.php @@ -0,0 +1,17 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Preview; + +class XBitmap extends Image { + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/image\/x-xbitmap/'; + } +} |