From e49f4473e9d45065c28e6199510d9534acd0fd20 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 19 Feb 2026 14:51:25 +0100 Subject: [PATCH 1/3] feat: make preview conversion timeout and max file size configurable Signed-off-by: Julius Knorr --- docs/app_settings.md | 24 +++++++ lib/AppConfig.php | 24 +++++++ lib/Preview/Office.php | 14 ++++- lib/Service/RemoteService.php | 13 ++-- tests/lib/AppConfigTest.php | 36 +++++++++++ tests/lib/Preview/OfficeTest.php | 103 +++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 tests/lib/Preview/OfficeTest.php diff --git a/docs/app_settings.md b/docs/app_settings.md index 699effde41..07e533239d 100644 --- a/docs/app_settings.md +++ b/docs/app_settings.md @@ -25,6 +25,30 @@ token. These credentials then can be used by the 3rd party application to make c ### Canonical webroot Canonical webroot, in case there are multiple, for Collabora Online to use. Provide the one with least restrictions. E.g.: Use non-shibbolized webroot if this instance is accessed by both shibbolized and non-shibbolized webroots. You can ignore this setting if only one webroot is used to access this instance. +### Theme + +By default Nextcloud Office comes with Nextcloud theme (monochrome icons following Nextcloud style), to change to a more traditional office look: + + occ config:app:set richdocuments theme --value="collabora" + +To go back to default theme: + + occ config:app:set richdocuments theme --value="nextcloud" + +### Previews + +By default Nextcloud will generate previews of Office files using the Collabora file conversion endpoint. This can be turned off through + + 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 3e0ee1ddb0..f1689eb6b6 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -30,9 +30,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' => [], @@ -240,6 +245,25 @@ private function getFederationDomains(): array { return array_map(fn ($url) => $this->domainOnly($url), array_merge($trustedNextcloudDomains, $trustedCollaboraDomains)); } + 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 8b8caa8390..7b90da76ae 100644 --- a/lib/Preview/Office.php +++ b/lib/Preview/Office.php @@ -33,12 +33,22 @@ public function isAvailable(FileInfo $file): bool { } 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; } try { - $response = $this->remoteService->convertFileTo($file, 'png'); + $response = $this->remoteService->convertFileTo($file, 'png', $this->appConfig->getPreviewConversionTimeout()); $image = new Image(); $image->loadFromData($response); diff --git a/lib/Service/RemoteService.php b/lib/Service/RemoteService.php index ac2df4d102..c3ee6fe095 100644 --- a/lib/Service/RemoteService.php +++ b/lib/Service/RemoteService.php @@ -57,23 +57,28 @@ public function fetchTargetThumbnail(File $file, string $target): ?string { /** * @return resource|string */ - public function convertFileTo(File $file, string $format) { + public function convertFileTo(File $file, string $format, int $timeout = RemoteOptionsService::REMOTE_TIMEOUT_DEFAULT) { $fileName = $file->getStorage()->getLocalFile($file->getInternalPath()); $stream = fopen($fileName, 'rb'); if ($stream === false) { throw new Exception('Failed to open stream'); } - return $this->convertTo($file->getName(), $stream, $format); + + try { + return $this->convertTo($file->getName(), $stream, $format, [], $timeout); + } finally { + fclose($stream); + } } /** * @param resource $stream * @return resource|string */ - public function convertTo(string $filename, $stream, string $format) { + 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 1f9296427f..c9722f1fff 100644 --- a/tests/lib/AppConfigTest.php +++ b/tests/lib/AppConfigTest.php @@ -58,4 +58,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); + } +} From ed96925f5d1b9bce9d8b1dee30f49426a7a4abed Mon Sep 17 00:00:00 2001 From: Elizabeth Danzberger Date: Mon, 8 Jun 2026 19:35:16 -0400 Subject: [PATCH 2/3] fix: use config in line with 31 Signed-off-by: Elizabeth Danzberger --- lib/AppConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/AppConfig.php b/lib/AppConfig.php index f1689eb6b6..b236c6c0ce 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -246,7 +246,7 @@ private function getFederationDomains(): array { } public function isPreviewGenerationEnabled(): bool { - return $this->appConfig->getAppValueBool('preview_generation', true); + return $this->config->getAppValue(Application::APPNAME, 'preview_generation', 'true') !== 'false'; } /** From d3c5c6ddf3a69e144648eab30b3758af2760c708 Mon Sep 17 00:00:00 2001 From: Elizabeth Danzberger Date: Mon, 8 Jun 2026 19:35:29 -0400 Subject: [PATCH 3/3] fix: use app config Signed-off-by: Elizabeth Danzberger --- lib/Preview/Office.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Preview/Office.php b/lib/Preview/Office.php index 7b90da76ae..f12b9e0e16 100644 --- a/lib/Preview/Office.php +++ b/lib/Preview/Office.php @@ -5,6 +5,7 @@ */ namespace OCA\Richdocuments\Preview; +use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Capabilities; use OCA\Richdocuments\Service\RemoteService; use OCP\Files\File; @@ -20,6 +21,7 @@ abstract class Office implements IProviderV2 { public function __construct( private RemoteService $remoteService, private LoggerInterface $logger, + private AppConfig $appConfig, Capabilities $capabilities, ) { $this->capabilities = $capabilities->getCapabilities()['richdocuments'] ?? [];