Skip to content

Commit dd2c8d9

Browse files
Merge pull request #58274 from nextcloud/backport-stable33-58230
[stable33]: fix(preview): Fix files:app-data-scan for previews
2 parents 8988c0c + 4350a8d commit dd2c8d9

File tree

8 files changed

+349
-122
lines changed

8 files changed

+349
-122
lines changed

apps/files/lib/Command/ScanAppData.php

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ protected function configure(): void {
5353
$this->addArgument('folder', InputArgument::OPTIONAL, 'The appdata subfolder to scan', '');
5454
}
5555

56+
protected function getScanner(OutputInterface $output): Scanner {
57+
$connection = $this->reconnectToDatabase($output);
58+
return new Scanner(
59+
null,
60+
new ConnectionAdapter($connection),
61+
Server::get(IEventDispatcher::class),
62+
Server::get(LoggerInterface::class),
63+
);
64+
}
65+
5666
protected function scanFiles(OutputInterface $output, string $folder): int {
5767
if ($folder === 'preview' || $folder === '') {
5868
$this->previewsCounter = $this->previewStorage->scan();
@@ -79,13 +89,7 @@ protected function scanFiles(OutputInterface $output, string $folder): int {
7989
}
8090
}
8191

82-
$connection = $this->reconnectToDatabase($output);
83-
$scanner = new Scanner(
84-
null,
85-
new ConnectionAdapter($connection),
86-
Server::get(IEventDispatcher::class),
87-
Server::get(LoggerInterface::class)
88-
);
92+
$scanner = $this->getScanner($output);
8993

9094
# check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
9195
$scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output): void {
@@ -142,6 +146,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
142146

143147
$folder = $input->getArgument('folder');
144148

149+
// Start the timer
150+
$this->execTime = -microtime(true);
151+
145152
$this->initTools();
146153

147154
$exitCode = $this->scanFiles($output, $folder);
@@ -155,8 +162,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
155162
* Initialises some useful tools for the Command
156163
*/
157164
protected function initTools(): void {
158-
// Start the timer
159-
$this->execTime = -microtime(true);
160165
// Convert PHP errors to exceptions
161166
set_error_handler([$this, 'exceptionErrorHandler'], E_ALL);
162167
}
@@ -210,6 +215,11 @@ protected function showSummary(array $headers, ?array $rows, OutputInterface $ou
210215
$rows[] = $this->filesCounter;
211216
$rows[] = $niceDate;
212217
}
218+
219+
$this->displayTable($output, $headers, $rows);
220+
}
221+
222+
protected function displayTable($output, $headers, $rows): void {
213223
$table = new Table($output);
214224
$table
215225
->setHeaders($headers)
@@ -250,9 +260,9 @@ protected function reconnectToDatabase(OutputInterface $output): Connection {
250260
* @throws NotFoundException
251261
*/
252262
private function getAppDataFolder(): Node {
253-
$instanceId = $this->config->getSystemValue('instanceid', null);
263+
$instanceId = $this->config->getSystemValueString('instanceid', '');
254264

255-
if ($instanceId === null) {
265+
if ($instanceId === '') {
256266
throw new NotFoundException();
257267
}
258268

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH
7+
* SPDX-FileContributor: Carl Schwan
8+
* SPDX-License-Identifier: AGPL-3.0-or-later
9+
*/
10+
11+
namespace OCA\Files\Tests\Command;
12+
13+
use OC\Files\Mount\ObjectHomeMountProvider;
14+
use OC\Files\SetupManager;
15+
use OC\Files\Utils\Scanner;
16+
use OC\Preview\Db\Preview;
17+
use OC\Preview\Db\PreviewMapper;
18+
use OC\Preview\PreviewService;
19+
use OC\Preview\Storage\StorageFactory;
20+
use OCA\Files\Command\ScanAppData;
21+
use OCP\Files\Folder;
22+
use OCP\Files\IMimeTypeDetector;
23+
use OCP\Files\IMimeTypeLoader;
24+
use OCP\Files\IRootFolder;
25+
use OCP\Files\NotFoundException;
26+
use OCP\Files\Storage\IStorageFactory;
27+
use OCP\IAppConfig;
28+
use OCP\IConfig;
29+
use OCP\IUser;
30+
use OCP\IUserManager;
31+
use OCP\IUserSession;
32+
use OCP\Server;
33+
use PHPUnit\Framework\Attributes\DataProvider;
34+
use PHPUnit\Framework\Attributes\Group;
35+
use PHPUnit\Framework\MockObject\MockObject;
36+
use Symfony\Component\Console\Input\InputInterface;
37+
use Symfony\Component\Console\Output\OutputInterface;
38+
use Test\TestCase;
39+
40+
#[Group(name: 'DB')]
41+
class ScanAppDataTest extends TestCase {
42+
private IRootFolder $rootFolder;
43+
private IConfig $config;
44+
private StorageFactory $storageFactory;
45+
private OutputInterface&MockObject $output;
46+
private InputInterface&MockObject $input;
47+
private Scanner&MockObject $internalScanner;
48+
private ScanAppData $scanner;
49+
private string $user;
50+
51+
public function setUp(): void {
52+
$this->config = Server::get(IConfig::class);
53+
$this->rootFolder = Server::get(IRootFolder::class);
54+
$this->storageFactory = Server::get(StorageFactory::class);
55+
$this->user = static::getUniqueID('user');
56+
$user = Server::get(IUserManager::class)->createUser($this->user, 'test');
57+
Server::get(SetupManager::class)->setupForUser($user);
58+
Server::get(IUserSession::class)->setUser($user);
59+
$this->output = $this->createMock(OutputInterface::class);
60+
$this->input = $this->createMock(InputInterface::class);
61+
$this->scanner = $this->getMockBuilder(ScanAppData::class)
62+
->onlyMethods(['displayTable', 'initTools', 'getScanner'])
63+
->setConstructorArgs([$this->rootFolder, $this->config, $this->storageFactory])
64+
->getMock();
65+
$this->internalScanner = $this->getMockBuilder(Scanner::class)
66+
->onlyMethods(['scan'])
67+
->disableOriginalConstructor()
68+
->getMock();
69+
$this->scanner->method('getScanner')->willReturn($this->internalScanner);
70+
71+
$this->scanner->method('initTools')
72+
->willReturnCallback(function () {});
73+
try {
74+
$this->rootFolder->get($this->rootFolder->getAppDataDirectoryName() . '/preview')->delete();
75+
} catch (NotFoundException) {
76+
}
77+
78+
Server::get(PreviewService::class)->deleteAll();
79+
80+
try {
81+
$appDataFolder = $this->rootFolder->get($this->rootFolder->getAppDataDirectoryName());
82+
} catch (NotFoundException) {
83+
$appDataFolder = $this->rootFolder->newFolder($this->rootFolder->getAppDataDirectoryName());
84+
}
85+
86+
$appDataFolder->newFolder('preview');
87+
}
88+
89+
public function tearDown(): void {
90+
Server::get(IUserManager::class)->get($this->user)->delete();
91+
Server::get(IUserSession::class)->setUser(null);
92+
$this->rootFolder->get($this->rootFolder->getAppDataDirectoryName())->delete();
93+
parent::tearDown();
94+
}
95+
96+
public function testScanAppDataRoot(): void {
97+
$homeProvider = Server::get(ObjectHomeMountProvider::class);
98+
$user = $this->createMock(IUser::class);
99+
$user->method('getUID')
100+
->willReturn('foo');
101+
if ($homeProvider->getHomeMountForUser($user, $this->createMock(IStorageFactory::class)) !== null) {
102+
$this->markTestSkipped();
103+
}
104+
105+
$this->input->method('getArgument')->with('folder')->willReturn('');
106+
$this->internalScanner->method('scan')->willReturnCallback(function () {
107+
$this->internalScanner->emit('\OC\Files\Utils\Scanner', 'scanFile', ['path42']);
108+
$this->internalScanner->emit('\OC\Files\Utils\Scanner', 'scanFolder', ['path42']);
109+
$this->internalScanner->emit('\OC\Files\Utils\Scanner', 'scanFolder', ['path42']);
110+
});
111+
$this->scanner->expects($this->once())->method('displayTable')
112+
->willReturnCallback(function (OutputInterface $output, array $headers, array $rows): void {
113+
$this->assertEquals($this->output, $output);
114+
$this->assertEquals(['Previews', 'Folders', 'Files', 'Elapsed time'], $headers);
115+
$this->assertEquals(0, $rows[0]);
116+
$this->assertEquals(2, $rows[1]);
117+
$this->assertEquals(1, $rows[2]);
118+
});
119+
120+
$errorCode = $this->invokePrivate($this->scanner, 'execute', [$this->input, $this->output]);
121+
$this->assertEquals(ScanAppData::SUCCESS, $errorCode);
122+
}
123+
124+
125+
public static function scanPreviewLocalData(): \Generator {
126+
yield 'initial migration done' => [true, null];
127+
yield 'initial migration not done' => [false, false];
128+
yield 'initial migration not done with legacy paths' => [false, true];
129+
}
130+
131+
#[DataProvider(methodName: 'scanPreviewLocalData')]
132+
public function testScanAppDataPreviewOnlyLocalFile(bool $migrationDone, ?bool $legacy): void {
133+
$homeProvider = Server::get(ObjectHomeMountProvider::class);
134+
$user = $this->createMock(IUser::class);
135+
$user->method('getUID')
136+
->willReturn('foo');
137+
if ($homeProvider->getHomeMountForUser($user, $this->createMock(IStorageFactory::class)) !== null) {
138+
$this->markTestSkipped();
139+
}
140+
$this->input->method('getArgument')->with('folder')->willReturn('preview');
141+
142+
$file = $this->rootFolder->getUserFolder($this->user)->newFile('myfile.jpeg');
143+
144+
if ($migrationDone) {
145+
Server::get(IAppConfig::class)->setValueBool('core', 'previewMovedDone', true);
146+
$preview = new Preview();
147+
$preview->generateId();
148+
$preview->setFileId($file->getId());
149+
$preview->setStorageId($file->getStorage()->getCache()->getNumericStorageId());
150+
$preview->setEtag('abc');
151+
$preview->setMtime(1);
152+
$preview->setWidth(1024);
153+
$preview->setHeight(1024);
154+
$preview->setMimeType('image/jpeg');
155+
$preview->setSize($this->storageFactory->writePreview($preview, 'preview content'));
156+
Server::get(PreviewMapper::class)->insert($preview);
157+
158+
$preview = new Preview();
159+
$preview->generateId();
160+
$preview->setFileId($file->getId());
161+
$preview->setStorageId($file->getStorage()->getCache()->getNumericStorageId());
162+
$preview->setEtag('abc');
163+
$preview->setMtime(1);
164+
$preview->setWidth(2024);
165+
$preview->setHeight(2024);
166+
$preview->setMax(true);
167+
$preview->setMimeType('image/jpeg');
168+
$preview->setSize($this->storageFactory->writePreview($preview, 'preview content'));
169+
Server::get(PreviewMapper::class)->insert($preview);
170+
171+
$preview = new Preview();
172+
$preview->generateId();
173+
$preview->setFileId($file->getId());
174+
$preview->setStorageId($file->getStorage()->getCache()->getNumericStorageId());
175+
$preview->setEtag('abc');
176+
$preview->setMtime(1);
177+
$preview->setWidth(2024);
178+
$preview->setHeight(2024);
179+
$preview->setMax(true);
180+
$preview->setCropped(true);
181+
$preview->setMimeType('image/jpeg');
182+
$preview->setSize($this->storageFactory->writePreview($preview, 'preview content'));
183+
Server::get(PreviewMapper::class)->insert($preview);
184+
185+
$previews = Server::get(PreviewService::class)->getAvailablePreviews([$file->getId()]);
186+
$this->assertCount(3, $previews[$file->getId()]);
187+
} else {
188+
Server::get(IAppConfig::class)->setValueBool('core', 'previewMovedDone', false);
189+
/** @var Folder $previewFolder */
190+
$previewFolder = $this->rootFolder->get($this->rootFolder->getAppDataDirectoryName() . '/preview');
191+
if (!$legacy) {
192+
foreach (str_split(substr(md5((string)$file->getId()), 0, 7)) as $subPath) {
193+
$previewFolder = $previewFolder->newFolder($subPath);
194+
}
195+
}
196+
$previewFolder = $previewFolder->newFolder((string)$file->getId());
197+
$previewFolder->newFile('1024-1024.jpg');
198+
$previewFolder->newFile('2024-2024-max.jpg');
199+
$previewFolder->newFile('2024-2024-max-crop.jpg');
200+
201+
$this->assertCount(3, $previewFolder->getDirectoryListing());
202+
203+
$previews = Server::get(PreviewService::class)->getAvailablePreviews([$file->getId()]);
204+
$this->assertCount(0, $previews[$file->getId()]);
205+
}
206+
207+
$mimetypeDetector = $this->createMock(IMimeTypeDetector::class);
208+
$mimetypeDetector->method('detectPath')->willReturn('image/jpeg');
209+
210+
$appConfig = $this->createMock(IAppConfig::class);
211+
$appConfig->method('getValueBool')->with('core', 'previewMovedDone')->willReturn($migrationDone);
212+
213+
$mimetypeLoader = $this->createMock(IMimeTypeLoader::class);
214+
$mimetypeLoader->method('getMimetypeById')->willReturn('image/jpeg');
215+
216+
$this->scanner->expects($this->once())->method('displayTable')
217+
->willReturnCallback(function ($output, array $headers, array $rows): void {
218+
$this->assertEquals($output, $this->output);
219+
$this->assertEquals(['Previews', 'Folders', 'Files', 'Elapsed time'], $headers);
220+
$this->assertEquals(3, $rows[0]);
221+
$this->assertEquals(0, $rows[1]);
222+
$this->assertEquals(0, $rows[2]);
223+
});
224+
$errorCode = $this->invokePrivate($this->scanner, 'execute', [$this->input, $this->output]);
225+
$this->assertEquals(ScanAppData::SUCCESS, $errorCode);
226+
227+
/** @var Folder $previewFolder */
228+
$previewFolder = $this->rootFolder->get($this->rootFolder->getAppDataDirectoryName() . '/preview');
229+
$children = $previewFolder->getDirectoryListing();
230+
$this->assertCount(0, $children);
231+
232+
Server::get(PreviewService::class)->deleteAll();
233+
}
234+
}

build/psalm-baseline.xml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,9 +1287,6 @@
12871287
<InvalidArgument>
12881288
<code><![CDATA[[$this, 'exceptionErrorHandler']]]></code>
12891289
</InvalidArgument>
1290-
<NullArgument>
1291-
<code><![CDATA[null]]></code>
1292-
</NullArgument>
12931290
</file>
12941291
<file src="apps/files/lib/Controller/ApiController.php">
12951292
<DeprecatedMethod>

lib/private/Files/Cache/Scanner.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ protected function scanChildren(string $path, $recursive, int $reuse, int $folde
451451
* @param bool|IScanner::SCAN_RECURSIVE_INCOMPLETE $recursive
452452
*/
453453
private function handleChildren(string $path, $recursive, int $reuse, int $folderId, bool $lock, int|float &$size, bool &$etagChanged): array {
454-
// we put this in it's own function so it cleans up the memory before we start recursing
454+
// we put this in its own function so it cleans up the memory before we start recursing
455455
$existingChildren = $this->getExistingChildren($folderId);
456456
$newChildren = iterator_to_array($this->storage->getDirectoryContent($path));
457457

lib/private/Files/Utils/Scanner.php

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,41 +44,22 @@
4444
class Scanner extends PublicEmitter {
4545
public const MAX_ENTRIES_TO_COMMIT = 10000;
4646

47-
/** @var string $user */
48-
private $user;
49-
50-
/** @var IDBConnection */
51-
protected $db;
52-
53-
/** @var IEventDispatcher */
54-
private $dispatcher;
55-
56-
protected LoggerInterface $logger;
57-
5847
/**
5948
* Whether to use a DB transaction
60-
*
61-
* @var bool
6249
*/
63-
protected $useTransaction;
50+
protected bool $useTransaction;
6451

6552
/**
6653
* Number of entries scanned to commit
67-
*
68-
* @var int
6954
*/
70-
protected $entriesToCommit;
55+
protected int $entriesToCommit = 0;
7156

72-
/**
73-
* @param string $user
74-
* @param IDBConnection|null $db
75-
* @param IEventDispatcher $dispatcher
76-
*/
77-
public function __construct($user, $db, IEventDispatcher $dispatcher, LoggerInterface $logger) {
78-
$this->user = $user;
79-
$this->db = $db;
80-
$this->dispatcher = $dispatcher;
81-
$this->logger = $logger;
57+
public function __construct(
58+
private ?string $user,
59+
protected ?IDBConnection $db,
60+
private IEventDispatcher $dispatcher,
61+
protected LoggerInterface $logger,
62+
) {
8263
// when DB locking is used, no DB transactions will be used
8364
$this->useTransaction = !(\OC::$server->get(ILockingProvider::class) instanceof DBLockingProvider);
8465
}

0 commit comments

Comments
 (0)