Skip to content

Commit 4dad7f1

Browse files
committed
feat(files): provide UI to sanitize filenames after enabling WCF
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent d785bcd commit 4dad7f1

16 files changed

Lines changed: 727 additions & 75 deletions

apps/files/appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
</commands>
6060

6161
<settings>
62+
<admin>OCA\Files\Settings\AdminSettings</admin>
6263
<personal>OCA\Files\Settings\PersonalSettings</personal>
6364
</settings>
6465

apps/files/composer/composer/autoload_classmap.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
'OCA\\Files\\BackgroundJob\\CleanupFileLocks' => $baseDir . '/../lib/BackgroundJob/CleanupFileLocks.php',
2424
'OCA\\Files\\BackgroundJob\\DeleteExpiredOpenLocalEditor' => $baseDir . '/../lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php',
2525
'OCA\\Files\\BackgroundJob\\DeleteOrphanedItems' => $baseDir . '/../lib/BackgroundJob/DeleteOrphanedItems.php',
26+
'OCA\\Files\\BackgroundJob\\SanitizeFilenames' => $baseDir . '/../lib/BackgroundJob/SanitizeFilenames.php',
2627
'OCA\\Files\\BackgroundJob\\ScanFiles' => $baseDir . '/../lib/BackgroundJob/ScanFiles.php',
2728
'OCA\\Files\\BackgroundJob\\TransferOwnership' => $baseDir . '/../lib/BackgroundJob/TransferOwnership.php',
2829
'OCA\\Files\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
@@ -53,6 +54,7 @@
5354
'OCA\\Files\\Controller\\ConversionApiController' => $baseDir . '/../lib/Controller/ConversionApiController.php',
5455
'OCA\\Files\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php',
5556
'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php',
57+
'OCA\\Files\\Controller\\FilenamesController' => $baseDir . '/../lib/Controller/FilenamesController.php',
5658
'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php',
5759
'OCA\\Files\\Controller\\TemplateController' => $baseDir . '/../lib/Controller/TemplateController.php',
5860
'OCA\\Files\\Controller\\TransferOwnershipController' => $baseDir . '/../lib/Controller/TransferOwnershipController.php',
@@ -88,6 +90,6 @@
8890
'OCA\\Files\\Service\\TagService' => $baseDir . '/../lib/Service/TagService.php',
8991
'OCA\\Files\\Service\\UserConfig' => $baseDir . '/../lib/Service/UserConfig.php',
9092
'OCA\\Files\\Service\\ViewConfig' => $baseDir . '/../lib/Service/ViewConfig.php',
91-
'OCA\\Files\\Settings\\DeclarativeAdminSettings' => $baseDir . '/../lib/Settings/DeclarativeAdminSettings.php',
93+
'OCA\\Files\\Settings\\AdminSettings' => $baseDir . '/../lib/Settings/AdminSettings.php',
9294
'OCA\\Files\\Settings\\PersonalSettings' => $baseDir . '/../lib/Settings/PersonalSettings.php',
9395
);

apps/files/composer/composer/autoload_static.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class ComposerStaticInitFiles
3838
'OCA\\Files\\BackgroundJob\\CleanupFileLocks' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupFileLocks.php',
3939
'OCA\\Files\\BackgroundJob\\DeleteExpiredOpenLocalEditor' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php',
4040
'OCA\\Files\\BackgroundJob\\DeleteOrphanedItems' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteOrphanedItems.php',
41+
'OCA\\Files\\BackgroundJob\\SanitizeFilenames' => __DIR__ . '/..' . '/../lib/BackgroundJob/SanitizeFilenames.php',
4142
'OCA\\Files\\BackgroundJob\\ScanFiles' => __DIR__ . '/..' . '/../lib/BackgroundJob/ScanFiles.php',
4243
'OCA\\Files\\BackgroundJob\\TransferOwnership' => __DIR__ . '/..' . '/../lib/BackgroundJob/TransferOwnership.php',
4344
'OCA\\Files\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
@@ -68,6 +69,7 @@ class ComposerStaticInitFiles
6869
'OCA\\Files\\Controller\\ConversionApiController' => __DIR__ . '/..' . '/../lib/Controller/ConversionApiController.php',
6970
'OCA\\Files\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php',
7071
'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php',
72+
'OCA\\Files\\Controller\\FilenamesController' => __DIR__ . '/..' . '/../lib/Controller/FilenamesController.php',
7173
'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php',
7274
'OCA\\Files\\Controller\\TemplateController' => __DIR__ . '/..' . '/../lib/Controller/TemplateController.php',
7375
'OCA\\Files\\Controller\\TransferOwnershipController' => __DIR__ . '/..' . '/../lib/Controller/TransferOwnershipController.php',
@@ -103,7 +105,7 @@ class ComposerStaticInitFiles
103105
'OCA\\Files\\Service\\TagService' => __DIR__ . '/..' . '/../lib/Service/TagService.php',
104106
'OCA\\Files\\Service\\UserConfig' => __DIR__ . '/..' . '/../lib/Service/UserConfig.php',
105107
'OCA\\Files\\Service\\ViewConfig' => __DIR__ . '/..' . '/../lib/Service/ViewConfig.php',
106-
'OCA\\Files\\Settings\\DeclarativeAdminSettings' => __DIR__ . '/..' . '/../lib/Settings/DeclarativeAdminSettings.php',
108+
'OCA\\Files\\Settings\\AdminSettings' => __DIR__ . '/..' . '/../lib/Settings/AdminSettings.php',
107109
'OCA\\Files\\Settings\\PersonalSettings' => __DIR__ . '/..' . '/../lib/Settings/PersonalSettings.php',
108110
);
109111

apps/files/lib/AppInfo/Application.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
use OCA\Files\Service\TagService;
3030
use OCA\Files\Service\UserConfig;
3131
use OCA\Files\Service\ViewConfig;
32-
use OCA\Files\Settings\DeclarativeAdminSettings;
3332
use OCP\Activity\IManager as IActivityManager;
3433
use OCP\AppFramework\App;
3534
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -111,8 +110,6 @@ public function register(IRegistrationContext $context): void {
111110
$context->registerCapability(AdvancedCapabilities::class);
112111
$context->registerCapability(DirectEditingCapabilities::class);
113112

114-
$context->registerDeclarativeSettings(DeclarativeAdminSettings::class);
115-
116113
$context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class);
117114
$context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class);
118115
$context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class);
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\Files\BackgroundJob;
10+
11+
use OC\Files\SetupManager;
12+
use OCA\Files\AppInfo\Application;
13+
use OCA\Files\Service\SettingsService;
14+
use OCP\AppFramework\Services\IAppConfig;
15+
use OCP\AppFramework\Utility\ITimeFactory;
16+
use OCP\BackgroundJob\IJobList;
17+
use OCP\BackgroundJob\QueuedJob;
18+
use OCP\Config\IUserConfig;
19+
use OCP\Files\File;
20+
use OCP\Files\Folder;
21+
use OCP\Files\IFilenameValidator;
22+
use OCP\Files\IRootFolder;
23+
use OCP\Files\Node;
24+
use OCP\Files\NotFoundException;
25+
use OCP\IUser;
26+
use OCP\IUserManager;
27+
use OCP\IUserSession;
28+
use OCP\Lock\LockedException;
29+
use Psr\Log\LoggerInterface;
30+
31+
class SanitizeFilenames extends QueuedJob {
32+
33+
private int $offset;
34+
private int $limit;
35+
private int $currentIndex;
36+
private ?string $charReplacement = null;
37+
38+
public function __construct(
39+
ITimeFactory $time,
40+
private IJobList $jobList,
41+
private IUserSession $session,
42+
private IUserManager $manager,
43+
private IAppConfig $appConfig,
44+
private IUserConfig $userConfig,
45+
private IRootFolder $rootFolder,
46+
private SetupManager $setupManager,
47+
private IFilenameValidator $filenameValidator,
48+
private LoggerInterface $logger,
49+
) {
50+
parent::__construct($time);
51+
$this->setAllowParallelRuns(false);
52+
}
53+
54+
/**
55+
* Makes the background job do its work
56+
*
57+
* @param array $argument unused argument
58+
* @throws \Exception
59+
*/
60+
public function run($argument) {
61+
$this->charReplacement = strval($argument['charReplacement']) ?: null;
62+
if (isset($argument['errorsOnly'])) {
63+
$this->retryFailedNodes();
64+
return;
65+
}
66+
67+
$this->offset = intval($argument['offset']);
68+
$this->limit = intval($argument['limit']);
69+
if ($this->offset === 0) {
70+
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_RUNNING);
71+
}
72+
73+
$this->currentIndex = 0;
74+
foreach ($this->manager->getSeenUsers($this->offset) as $user) {
75+
$this->sanitizeUserFiles($user);
76+
$this->currentIndex++;
77+
$this->appConfig->setAppValueInt('sanitize_filenames_index', $this->currentIndex);
78+
79+
if ($this->currentIndex === $this->limit) {
80+
break;
81+
}
82+
}
83+
84+
if ($this->currentIndex === $this->limit) {
85+
$this->offset += $this->limit;
86+
$this->jobList->add(self::class, ['limit' => $this->limit, 'offset' => $this->offset, 'charReplacement' => $this->charReplacement]);
87+
return;
88+
}
89+
90+
// No index to process anymore, we are done
91+
$this->appConfig->deleteAppValue('sanitize_filenames_index');
92+
93+
$hasErrors = !empty($this->userConfig->getValuesByUsers(Application::APP_ID, 'sanitize_filenames_errors'));
94+
if ($hasErrors) {
95+
$this->logger->info('Filename sanitization finished with errors. Retrying failed files in next background job run.');
96+
$this->jobList->add(self::class, ['errorsOnly' => true, 'charReplacement' => $this->charReplacement]);
97+
return;
98+
}
99+
100+
// we are really done!
101+
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
102+
}
103+
104+
/**
105+
* Retry to sanitize files that failed in the first run
106+
*/
107+
private function retryFailedNodes(): void {
108+
$this->logger->debug('Retry sanitizing failed filename sanitization.');
109+
$results = $this->userConfig->getValuesByUsers(Application::APP_ID, 'sanitize_filenames_errors');
110+
111+
$hasErrors = false;
112+
foreach ($results as $userId => $errors) {
113+
$user = $this->manager->get($userId);
114+
if ($user === null) {
115+
// user got deleted meanwhile, ignore
116+
continue;
117+
}
118+
119+
$hasErrors = $hasErrors || $this->retryFailedUserNodes($user, $errors);
120+
$this->userConfig->deleteUserConfig($userId, Application::APP_ID, 'sanitize_filenames_errors');
121+
}
122+
123+
if ($hasErrors) {
124+
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_ERROR);
125+
$this->logger->error('Retrying filename sanitization failed permanently.');
126+
} else {
127+
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
128+
$this->logger->info('Retrying filename sanitization succeeded.');
129+
}
130+
}
131+
132+
private function retryFailedUserNodes(IUser $user, array $errors): bool {
133+
$this->session->setVolatileActiveUser($user);
134+
$folder = $this->rootFolder->getUserFolder($user->getUID());
135+
136+
$this->logger->debug("filename sanitization retry: started for user '{$user->getUID()}'");
137+
$hasErrors = false;
138+
foreach ($errors as $path) {
139+
try {
140+
$node = $folder->get($path);
141+
$this->sanitizeNode($node);
142+
} catch (NotFoundException) {
143+
// file got deleted meanwhile, ignore
144+
} catch (\Exception $error) {
145+
$this->logger->error('filename sanitization failed when retried: ' . $path, ['exception' => $error]);
146+
$hasErrors = true;
147+
}
148+
}
149+
150+
// tear down FS for user to make sure we do not run out of memory due to cached user FS
151+
$this->setupManager->tearDown();
152+
153+
return $hasErrors;
154+
}
155+
156+
157+
private function sanitizeUserFiles(IUser $user): void {
158+
// Set an active user so that event listeners can correctly work (e.g. files versions)
159+
$this->session->setVolatileActiveUser($user);
160+
$folder = $this->rootFolder->getUserFolder($user->getUID());
161+
162+
$this->logger->debug("filename sanitization: started for user '{$user->getUID()}'");
163+
$errors = $this->sanitizeFolder($folder);
164+
165+
// tear down FS for user to make sure we do not run out of memory due to cached user FS
166+
$this->setupManager->tearDown();
167+
168+
if (!empty($errors)) {
169+
$this->userConfig->setValueArray($user->getUID(), 'files', 'sanitize_filenames_errors', $errors, true);
170+
}
171+
}
172+
173+
/**
174+
* Sanitizes the filenames of all nodes in a folder
175+
*
176+
* @return list<string> list of nodes that could not be sanitized
177+
*/
178+
private function sanitizeFolder(Folder $folder): array {
179+
$errors = [];
180+
foreach ($folder->getDirectoryListing() as $node) {
181+
try {
182+
$this->sanitizeNode($node);
183+
} catch (LockedException) {
184+
$this->logger->debug('filename sanitization skipped: ' . $node->getPath() . ' (file is locked)');
185+
$errors[] = $node->getPath();
186+
} catch (\Exception $error) {
187+
$this->logger->warning('filename sanitization failed: ' . $node->getPath(), ['exception' => $error]);
188+
$errors[] = $node->getPath();
189+
}
190+
191+
if ($node instanceof Folder) {
192+
$errors = array_merge($errors, $this->sanitizeFolder($node));
193+
}
194+
}
195+
return $errors;
196+
}
197+
198+
/**
199+
* Sanitizes the filename of a single node
200+
*
201+
* @throws LockedException If the file is locked
202+
* @throws \Exception Unknown error
203+
*/
204+
private function sanitizeNode(Node $node): void {
205+
if ($node->isShared() && !$node->isUpdateable()) {
206+
// we cannot rename files in shares where we do not have permissions - we do it when sanitizing the owner's files
207+
return;
208+
}
209+
210+
try {
211+
$oldName = $node->getName();
212+
$newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
213+
if ($oldName !== $newName) {
214+
$newName = $node->getParent()->getNonExistingName($newName);
215+
$path = rtrim(dirname($node->getPath()), '/');
216+
217+
$node->move("$path/$newName");
218+
}
219+
} catch (NotFoundException) {
220+
// file got deleted meanwhile, ignore
221+
// or this is shared without permissions to rename it, ignore (owner will rename it)
222+
}
223+
}
224+
}

apps/files/lib/Command/SanitizeFilenames.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
use Exception;
1212
use OC\Core\Command\Base;
1313
use OC\Files\FilenameValidator;
14+
use OCA\Files\Service\SettingsService;
15+
use OCP\AppFramework\Services\IAppConfig;
1416
use OCP\Files\Folder;
1517
use OCP\Files\IRootFolder;
1618
use OCP\Files\NotPermittedException;
@@ -29,13 +31,16 @@ class SanitizeFilenames extends Base {
2931
private OutputInterface $output;
3032
private ?string $charReplacement;
3133
private bool $dryRun;
34+
private bool $errorsOrSkipped = false;
3235

3336
public function __construct(
3437
private IUserManager $userManager,
3538
private IRootFolder $rootFolder,
3639
private IUserSession $session,
3740
private IFactory $l10nFactory,
3841
private FilenameValidator $filenameValidator,
42+
private SettingsService $service,
43+
private IAppConfig $appConfig,
3944
) {
4045
parent::__construct();
4146
}
@@ -100,6 +105,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
100105
}
101106
} else {
102107
$this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
108+
if ($this->service->hasFilesWindowsSupport() && $this->appConfig->getAppValueInt('sanitize_filenames_status') === 0) {
109+
// we are done - if this is for sanitizing all users for windows filename support then set this UI flag
110+
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
111+
}
103112
}
104113
return self::SUCCESS;
105114
}

0 commit comments

Comments
 (0)