diff --git a/docs/app_settings.md b/docs/app_settings.md index b0c7ac2014..66e5d176b1 100644 --- a/docs/app_settings.md +++ b/docs/app_settings.md @@ -41,6 +41,14 @@ By default Nextcloud will generate previews of Office files using the Collabora occ config:app:set richdocuments preview_generation --type boolean --lazy --value false +The timeout for preview conversion requests (in seconds) can be configured. The default is 5 seconds: + + occ config:app:set richdocuments preview_conversion_timeout --type integer --value 10 + +Files larger than the configured maximum file size will be skipped and no preview will be generated. The default limit is 100 MB (104857600 bytes): + + occ config:app:set richdocuments preview_conversion_max_filesize --type integer --value 52428800 + ### Electronic signature From a shell running in the Nextcloud root directory, run the following `occ` command to configure a non-default base URL for eID Easy. For example: diff --git a/lib/AppConfig.php b/lib/AppConfig.php index f3bc849f27..758c4bad7c 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -32,9 +32,14 @@ class AppConfig { // Default: 'no', set to 'yes' to enable public const USE_SECURE_VIEW_ADDITIONAL_MIMES = 'use_secure_view_additional_mimes'; + public const PREVIEW_CONVERSION_TIMEOUT = 'preview_conversion_timeout'; + public const PREVIEW_CONVERSION_MAX_FILESIZE = 'preview_conversion_max_filesize'; + private array $defaults = [ 'wopi_url' => '', 'timeout' => 15, + 'preview_conversion_timeout' => 5, + 'preview_conversion_max_filesize' => 104857600, // 100 MB 'watermark_text' => '{userId}', 'watermark_allGroupsList' => [], 'watermark_allTagsList' => [], @@ -248,6 +253,21 @@ public function isPreviewGenerationEnabled(): bool { return $this->appConfig->getAppValueBool('preview_generation', true); } + /** + * Returns the timeout in seconds for preview conversion requests to Collabora. + */ + public function getPreviewConversionTimeout(): int { + return (int)$this->getAppValue(self::PREVIEW_CONVERSION_TIMEOUT); + } + + /** + * Returns the maximum file size in bytes for which preview conversion is attempted. + * Files larger than this limit will be skipped and return no preview. + */ + public function getPreviewConversionMaxFileSize(): int { + return (int)$this->getAppValue(self::PREVIEW_CONVERSION_MAX_FILESIZE); + } + private function getGSDomains(): array { if (!$this->globalScaleConfig->isGlobalScaleEnabled()) { return []; diff --git a/lib/Preview/Office.php b/lib/Preview/Office.php index e663dc6b6a..5d342a2e75 100644 --- a/lib/Preview/Office.php +++ b/lib/Preview/Office.php @@ -38,7 +38,17 @@ public function isAvailable(FileInfo $file): bool { #[\Override] public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { - if ($file->getSize() === 0) { + $fileSize = $file->getSize(); + if ($fileSize === 0) { + return null; + } + + $maxFileSize = $this->appConfig->getPreviewConversionMaxFileSize(); + if ($fileSize > $maxFileSize) { + $this->logger->debug('Skipping preview conversion: file size {size} exceeds limit {limit}', [ + 'size' => $fileSize, + 'limit' => $maxFileSize, + ]); return null; } diff --git a/lib/Service/RemoteService.php b/lib/Service/RemoteService.php index 60776e614e..e319e057d1 100644 --- a/lib/Service/RemoteService.php +++ b/lib/Service/RemoteService.php @@ -66,16 +66,17 @@ public function convertFileTo(File $file, string $format) { if ($stream === false) { throw new Exception('Failed to open stream'); } - return $this->convertTo($file->getName(), $stream, $format); + $timeout = $this->appConfig->getPreviewConversionTimeout(); + return $this->convertTo($file->getName(), $stream, $format, [], $timeout); } /** * @param resource $stream * @return resource|string */ - public function convertTo(string $filename, $stream, string $format, ?array $conversionOptions = []) { + public function convertTo(string $filename, $stream, string $format, ?array $conversionOptions = [], int $timeout = RemoteOptionsService::REMOTE_TIMEOUT_DEFAULT) { $client = $this->clientService->newClient(); - $options = RemoteOptionsService::getDefaultOptions(); + $options = RemoteOptionsService::getDefaultOptions($timeout); // FIXME: can be removed once https://github.com/CollaboraOnline/online/issues/6983 is fixed upstream $options['expect'] = false; diff --git a/tests/lib/AppConfigTest.php b/tests/lib/AppConfigTest.php index 03fd045093..67ea34c6e0 100644 --- a/tests/lib/AppConfigTest.php +++ b/tests/lib/AppConfigTest.php @@ -63,4 +63,40 @@ public function testGetAppValueArrayWithNoneValue() { $this->assertSame([], $result); } + + public function testGetPreviewConversionTimeoutReturnsDefault(): void { + $this->config->expects($this->once()) + ->method('getAppValue') + ->with('richdocuments', 'preview_conversion_timeout', 5) + ->willReturn(5); + + $this->assertSame(5, $this->appConfig->getPreviewConversionTimeout()); + } + + public function testGetPreviewConversionTimeoutReturnsConfiguredValue(): void { + $this->config->expects($this->once()) + ->method('getAppValue') + ->with('richdocuments', 'preview_conversion_timeout', 5) + ->willReturn(30); + + $this->assertSame(30, $this->appConfig->getPreviewConversionTimeout()); + } + + public function testGetPreviewConversionMaxFileSizeReturnsDefault(): void { + $this->config->expects($this->once()) + ->method('getAppValue') + ->with('richdocuments', 'preview_conversion_max_filesize', 104857600) + ->willReturn(104857600); + + $this->assertSame(104857600, $this->appConfig->getPreviewConversionMaxFileSize()); + } + + public function testGetPreviewConversionMaxFileSizeReturnsConfiguredValue(): void { + $this->config->expects($this->once()) + ->method('getAppValue') + ->with('richdocuments', 'preview_conversion_max_filesize', 104857600) + ->willReturn(52428800); + + $this->assertSame(52428800, $this->appConfig->getPreviewConversionMaxFileSize()); + } } diff --git a/tests/lib/Preview/OfficeTest.php b/tests/lib/Preview/OfficeTest.php new file mode 100644 index 0000000000..f3bb6d19cc --- /dev/null +++ b/tests/lib/Preview/OfficeTest.php @@ -0,0 +1,103 @@ +remoteService = $this->createMock(RemoteService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->appConfig = $this->createMock(AppConfig::class); + $this->capabilities = $this->createMock(Capabilities::class); + $this->capabilities->method('getCapabilities')->willReturn(['richdocuments' => []]); + + $this->provider = new class($this->remoteService, $this->logger, $this->appConfig, $this->capabilities) extends Office { + #[\Override] + public function getMimeType(): string { + return '/application\\/test/'; + } + }; + } + + public function testGetThumbnailSkipsConversionWhenFileIsTooLarge(): void { + $file = $this->createMock(File::class); + $file->expects($this->once())->method('getSize')->willReturn(101 * 1024 * 1024); + + $this->appConfig->expects($this->once()) + ->method('getPreviewConversionMaxFileSize') + ->willReturn(100 * 1024 * 1024); + $this->remoteService->expects($this->never())->method('convertFileTo'); + + $result = $this->provider->getThumbnail($file, 64, 64); + + $this->assertNull($result); + } + + public function testGetThumbnailReturnsNullForEmptyFile(): void { + $file = $this->createMock(File::class); + $file->expects($this->once())->method('getSize')->willReturn(0); + + $this->appConfig->expects($this->never())->method('getPreviewConversionMaxFileSize'); + $this->remoteService->expects($this->never())->method('convertFileTo'); + + $result = $this->provider->getThumbnail($file, 64, 64); + + $this->assertNull($result); + } + + public function testGetThumbnailAttemptsConversionWhenFileSizeIsExactlyAtLimit(): void { + $file = $this->createMock(File::class); + $file->expects($this->once())->method('getSize')->willReturn(100 * 1024 * 1024); + + $this->appConfig->expects($this->once()) + ->method('getPreviewConversionMaxFileSize') + ->willReturn(100 * 1024 * 1024); + // Conversion is attempted; throw to keep the test simple (image loading is not unit-tested here) + $this->remoteService->expects($this->once()) + ->method('convertFileTo') + ->with($file, 'png') + ->willThrowException(new \Exception('conversion failed')); + + $result = $this->provider->getThumbnail($file, 64, 64); + + $this->assertNull($result); + } + + public function testGetThumbnailReturnsNullWhenConversionFails(): void { + $file = $this->createMock(File::class); + $file->expects($this->once())->method('getSize')->willReturn(1024); + + $this->appConfig->expects($this->once()) + ->method('getPreviewConversionMaxFileSize') + ->willReturn(100 * 1024 * 1024); + $this->remoteService->expects($this->once()) + ->method('convertFileTo') + ->with($file, 'png') + ->willThrowException(new \Exception('conversion failed')); + + $result = $this->provider->getThumbnail($file, 64, 64); + + $this->assertNull($result); + } +}