Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/app_settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions lib/AppConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +41 to +42
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new default entries use string literals ('preview_conversion_timeout', 'preview_conversion_max_filesize') even though constants for these keys were introduced just above. Using the constants here would reduce the chance of typos/mismatches if the key names ever change.

Suggested change
'preview_conversion_timeout' => 5,
'preview_conversion_max_filesize' => 104857600, // 100 MB
self::PREVIEW_CONVERSION_TIMEOUT => 5,
self::PREVIEW_CONVERSION_MAX_FILESIZE => 104857600, // 100 MB

Copilot uses AI. Check for mistakes.
'watermark_text' => '{userId}',
'watermark_allGroupsList' => [],
'watermark_allTagsList' => [],
Expand Down Expand Up @@ -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 [];
Expand Down
12 changes: 11 additions & 1 deletion lib/Preview/Office.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
7 changes: 4 additions & 3 deletions lib/Service/RemoteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

convertFileTo() opens a local file stream but never closes it. In exception cases (and potentially in long-running PHP processes), this can leak file descriptors.

Wrap the convertTo() call in a try/finally and fclose($stream) in the finally block.

Suggested change
return $this->convertTo($file->getName(), $stream, $format, [], $timeout);
try {
return $this->convertTo($file->getName(), $stream, $format, [], $timeout);
} finally {
fclose($stream);
}

Copilot uses AI. Check for mistakes.
}

Comment on lines +69 to 72
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

convertFileTo() now unconditionally uses getPreviewConversionTimeout(). This method is also used outside the preview code path (e.g. via ConversionProvider::convertFile()), so changing it makes the preview timeout setting affect non-preview conversions as well, which is inconsistent with the config name/docs and can change behavior unexpectedly.

Consider keeping convertFileTo() on the default timeout and instead passing the preview timeout from Office::getThumbnail() (e.g. by adding an optional timeout parameter to convertFileTo() or introducing a dedicated preview-only method).

Suggested change
$timeout = $this->appConfig->getPreviewConversionTimeout();
return $this->convertTo($file->getName(), $stream, $format, [], $timeout);
}
return $this->convertTo($file->getName(), $stream, $format);
}

Copilot uses AI. Check for mistakes.
/**
* @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;

Expand Down
36 changes: 36 additions & 0 deletions tests/lib/AppConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
103 changes: 103 additions & 0 deletions tests/lib/Preview/OfficeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace Tests\Richdocuments\Preview;

use OCA\Richdocuments\AppConfig;
use OCA\Richdocuments\Capabilities;
use OCA\Richdocuments\Preview\Office;
use OCA\Richdocuments\Service\RemoteService;
use OCP\Files\File;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

class OfficeTest extends TestCase {
private RemoteService&MockObject $remoteService;
private LoggerInterface&MockObject $logger;
private AppConfig&MockObject $appConfig;
private Capabilities&MockObject $capabilities;
private Office $provider;

protected function setUp(): void {
parent::setUp();

$this->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);
}
}
Loading