Skip to content

Commit 3b8c5ec

Browse files
committed
feat: make preview conversion timeout and max file size configurable
Signed-off-by: Julius Knorr <jus@bitgrid.net>
1 parent 94525d6 commit 3b8c5ec

6 files changed

Lines changed: 188 additions & 6 deletions

File tree

docs/app_settings.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ By default Nextcloud will generate previews of Office files using the Collabora
4141

4242
occ config:app:set richdocuments preview_generation --type boolean --lazy --value false
4343

44+
The timeout for preview conversion requests (in seconds) can be configured. The default is 5 seconds:
45+
46+
occ config:app:set richdocuments preview_conversion_timeout --type integer --value 10
47+
48+
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):
49+
50+
occ config:app:set richdocuments preview_conversion_max_filesize --type integer --value 52428800
51+
4452
### Electronic signature
4553
From a shell running in the Nextcloud root directory, run the following `occ`
4654
command to configure a non-default base URL for eID Easy. For example:

lib/AppConfig.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,14 @@ class AppConfig {
3232
// Default: 'no', set to 'yes' to enable
3333
public const USE_SECURE_VIEW_ADDITIONAL_MIMES = 'use_secure_view_additional_mimes';
3434

35+
public const PREVIEW_CONVERSION_TIMEOUT = 'preview_conversion_timeout';
36+
public const PREVIEW_CONVERSION_MAX_FILESIZE = 'preview_conversion_max_filesize';
37+
3538
private array $defaults = [
3639
'wopi_url' => '',
3740
'timeout' => 15,
41+
'preview_conversion_timeout' => 5,
42+
'preview_conversion_max_filesize' => 104857600, // 100 MB
3843
'watermark_text' => '{userId}',
3944
'watermark_allGroupsList' => [],
4045
'watermark_allTagsList' => [],
@@ -248,6 +253,21 @@ public function isPreviewGenerationEnabled(): bool {
248253
return $this->appConfig->getAppValueBool('preview_generation', true);
249254
}
250255

256+
/**
257+
* Returns the timeout in seconds for preview conversion requests to Collabora.
258+
*/
259+
public function getPreviewConversionTimeout(): int {
260+
return (int)$this->getAppValue(self::PREVIEW_CONVERSION_TIMEOUT);
261+
}
262+
263+
/**
264+
* Returns the maximum file size in bytes for which preview conversion is attempted.
265+
* Files larger than this limit will be skipped and return no preview.
266+
*/
267+
public function getPreviewConversionMaxFileSize(): int {
268+
return (int)$this->getAppValue(self::PREVIEW_CONVERSION_MAX_FILESIZE);
269+
}
270+
251271
private function getGSDomains(): array {
252272
if (!$this->globalScaleConfig->isGlobalScaleEnabled()) {
253273
return [];

lib/Preview/Office.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@ public function isAvailable(FileInfo $file): bool {
3838

3939
#[\Override]
4040
public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
41-
if ($file->getSize() === 0) {
41+
$fileSize = $file->getSize();
42+
if ($fileSize === 0) {
43+
return null;
44+
}
45+
46+
$maxFileSize = $this->appConfig->getPreviewConversionMaxFileSize();
47+
if ($fileSize > $maxFileSize) {
48+
$this->logger->debug('Skipping preview conversion: file size {size} exceeds limit {limit}', [
49+
'size' => $fileSize,
50+
'limit' => $maxFileSize,
51+
]);
4252
return null;
4353
}
4454

4555
try {
46-
$response = $this->remoteService->convertFileTo($file, 'png');
56+
$response = $this->remoteService->convertFileTo($file, 'png', $this->appConfig->getPreviewConversionTimeout());
4757
$image = new Image();
4858
$image->loadFromData($response);
4959

lib/Service/RemoteService.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,23 +59,28 @@ public function fetchTargetThumbnail(File $file, string $target): ?string {
5959
/**
6060
* @return resource|string
6161
*/
62-
public function convertFileTo(File $file, string $format) {
62+
public function convertFileTo(File $file, string $format, int $timeout = RemoteOptionsService::REMOTE_TIMEOUT_DEFAULT) {
6363
$fileName = $file->getStorage()->getLocalFile($file->getInternalPath());
6464
$stream = fopen($fileName, 'rb');
6565

6666
if ($stream === false) {
6767
throw new Exception('Failed to open stream');
6868
}
69-
return $this->convertTo($file->getName(), $stream, $format);
69+
70+
try {
71+
return $this->convertTo($file->getName(), $stream, $format, [], $timeout);
72+
} finally {
73+
fclose($stream);
74+
}
7075
}
7176

7277
/**
7378
* @param resource $stream
7479
* @return resource|string
7580
*/
76-
public function convertTo(string $filename, $stream, string $format, ?array $conversionOptions = []) {
81+
public function convertTo(string $filename, $stream, string $format, ?array $conversionOptions = [], int $timeout = RemoteOptionsService::REMOTE_TIMEOUT_DEFAULT) {
7782
$client = $this->clientService->newClient();
78-
$options = RemoteOptionsService::getDefaultOptions();
83+
$options = RemoteOptionsService::getDefaultOptions($timeout);
7984
// FIXME: can be removed once https://github.com/CollaboraOnline/online/issues/6983 is fixed upstream
8085
$options['expect'] = false;
8186

tests/lib/AppConfigTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,40 @@ public function testGetAppValueArrayWithNoneValue() {
6363

6464
$this->assertSame([], $result);
6565
}
66+
67+
public function testGetPreviewConversionTimeoutReturnsDefault(): void {
68+
$this->config->expects($this->once())
69+
->method('getAppValue')
70+
->with('richdocuments', 'preview_conversion_timeout', 5)
71+
->willReturn(5);
72+
73+
$this->assertSame(5, $this->appConfig->getPreviewConversionTimeout());
74+
}
75+
76+
public function testGetPreviewConversionTimeoutReturnsConfiguredValue(): void {
77+
$this->config->expects($this->once())
78+
->method('getAppValue')
79+
->with('richdocuments', 'preview_conversion_timeout', 5)
80+
->willReturn(30);
81+
82+
$this->assertSame(30, $this->appConfig->getPreviewConversionTimeout());
83+
}
84+
85+
public function testGetPreviewConversionMaxFileSizeReturnsDefault(): void {
86+
$this->config->expects($this->once())
87+
->method('getAppValue')
88+
->with('richdocuments', 'preview_conversion_max_filesize', 104857600)
89+
->willReturn(104857600);
90+
91+
$this->assertSame(104857600, $this->appConfig->getPreviewConversionMaxFileSize());
92+
}
93+
94+
public function testGetPreviewConversionMaxFileSizeReturnsConfiguredValue(): void {
95+
$this->config->expects($this->once())
96+
->method('getAppValue')
97+
->with('richdocuments', 'preview_conversion_max_filesize', 104857600)
98+
->willReturn(52428800);
99+
100+
$this->assertSame(52428800, $this->appConfig->getPreviewConversionMaxFileSize());
101+
}
66102
}

tests/lib/Preview/OfficeTest.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace Tests\Richdocuments\Preview;
9+
10+
use OCA\Richdocuments\AppConfig;
11+
use OCA\Richdocuments\Capabilities;
12+
use OCA\Richdocuments\Preview\Office;
13+
use OCA\Richdocuments\Service\RemoteService;
14+
use OCP\Files\File;
15+
use PHPUnit\Framework\MockObject\MockObject;
16+
use PHPUnit\Framework\TestCase;
17+
use Psr\Log\LoggerInterface;
18+
19+
class OfficeTest extends TestCase {
20+
private RemoteService&MockObject $remoteService;
21+
private LoggerInterface&MockObject $logger;
22+
private AppConfig&MockObject $appConfig;
23+
private Capabilities&MockObject $capabilities;
24+
private Office $provider;
25+
26+
protected function setUp(): void {
27+
parent::setUp();
28+
29+
$this->remoteService = $this->createMock(RemoteService::class);
30+
$this->logger = $this->createMock(LoggerInterface::class);
31+
$this->appConfig = $this->createMock(AppConfig::class);
32+
$this->capabilities = $this->createMock(Capabilities::class);
33+
$this->capabilities->method('getCapabilities')->willReturn(['richdocuments' => []]);
34+
35+
$this->provider = new class($this->remoteService, $this->logger, $this->appConfig, $this->capabilities) extends Office {
36+
#[\Override]
37+
public function getMimeType(): string {
38+
return '/application\\/test/';
39+
}
40+
};
41+
}
42+
43+
public function testGetThumbnailSkipsConversionWhenFileIsTooLarge(): void {
44+
$file = $this->createMock(File::class);
45+
$file->expects($this->once())->method('getSize')->willReturn(101 * 1024 * 1024);
46+
47+
$this->appConfig->expects($this->once())
48+
->method('getPreviewConversionMaxFileSize')
49+
->willReturn(100 * 1024 * 1024);
50+
$this->remoteService->expects($this->never())->method('convertFileTo');
51+
52+
$result = $this->provider->getThumbnail($file, 64, 64);
53+
54+
$this->assertNull($result);
55+
}
56+
57+
public function testGetThumbnailReturnsNullForEmptyFile(): void {
58+
$file = $this->createMock(File::class);
59+
$file->expects($this->once())->method('getSize')->willReturn(0);
60+
61+
$this->appConfig->expects($this->never())->method('getPreviewConversionMaxFileSize');
62+
$this->remoteService->expects($this->never())->method('convertFileTo');
63+
64+
$result = $this->provider->getThumbnail($file, 64, 64);
65+
66+
$this->assertNull($result);
67+
}
68+
69+
public function testGetThumbnailAttemptsConversionWhenFileSizeIsExactlyAtLimit(): void {
70+
$file = $this->createMock(File::class);
71+
$file->expects($this->once())->method('getSize')->willReturn(100 * 1024 * 1024);
72+
73+
$this->appConfig->expects($this->once())
74+
->method('getPreviewConversionMaxFileSize')
75+
->willReturn(100 * 1024 * 1024);
76+
// Conversion is attempted; throw to keep the test simple (image loading is not unit-tested here)
77+
$this->remoteService->expects($this->once())
78+
->method('convertFileTo')
79+
->with($file, 'png')
80+
->willThrowException(new \Exception('conversion failed'));
81+
82+
$result = $this->provider->getThumbnail($file, 64, 64);
83+
84+
$this->assertNull($result);
85+
}
86+
87+
public function testGetThumbnailReturnsNullWhenConversionFails(): void {
88+
$file = $this->createMock(File::class);
89+
$file->expects($this->once())->method('getSize')->willReturn(1024);
90+
91+
$this->appConfig->expects($this->once())
92+
->method('getPreviewConversionMaxFileSize')
93+
->willReturn(100 * 1024 * 1024);
94+
$this->remoteService->expects($this->once())
95+
->method('convertFileTo')
96+
->with($file, 'png')
97+
->willThrowException(new \Exception('conversion failed'));
98+
99+
$result = $this->provider->getThumbnail($file, 64, 64);
100+
101+
$this->assertNull($result);
102+
}
103+
}

0 commit comments

Comments
 (0)