Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/files/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
</commands>

<settings>
<admin>OCA\Files\Settings\AdminSettings</admin>
<personal>OCA\Files\Settings\PersonalSettings</personal>
</settings>

Expand Down
4 changes: 3 additions & 1 deletion apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'OCA\\Files\\BackgroundJob\\CleanupFileLocks' => $baseDir . '/../lib/BackgroundJob/CleanupFileLocks.php',
'OCA\\Files\\BackgroundJob\\DeleteExpiredOpenLocalEditor' => $baseDir . '/../lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php',
'OCA\\Files\\BackgroundJob\\DeleteOrphanedItems' => $baseDir . '/../lib/BackgroundJob/DeleteOrphanedItems.php',
'OCA\\Files\\BackgroundJob\\SanitizeFilenames' => $baseDir . '/../lib/BackgroundJob/SanitizeFilenames.php',
'OCA\\Files\\BackgroundJob\\ScanFiles' => $baseDir . '/../lib/BackgroundJob/ScanFiles.php',
'OCA\\Files\\BackgroundJob\\TransferOwnership' => $baseDir . '/../lib/BackgroundJob/TransferOwnership.php',
'OCA\\Files\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
Expand Down Expand Up @@ -53,6 +54,7 @@
'OCA\\Files\\Controller\\ConversionApiController' => $baseDir . '/../lib/Controller/ConversionApiController.php',
'OCA\\Files\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php',
'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php',
'OCA\\Files\\Controller\\FilenamesController' => $baseDir . '/../lib/Controller/FilenamesController.php',
'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php',
'OCA\\Files\\Controller\\TemplateController' => $baseDir . '/../lib/Controller/TemplateController.php',
'OCA\\Files\\Controller\\TransferOwnershipController' => $baseDir . '/../lib/Controller/TransferOwnershipController.php',
Expand Down Expand Up @@ -88,6 +90,6 @@
'OCA\\Files\\Service\\TagService' => $baseDir . '/../lib/Service/TagService.php',
'OCA\\Files\\Service\\UserConfig' => $baseDir . '/../lib/Service/UserConfig.php',
'OCA\\Files\\Service\\ViewConfig' => $baseDir . '/../lib/Service/ViewConfig.php',
'OCA\\Files\\Settings\\DeclarativeAdminSettings' => $baseDir . '/../lib/Settings/DeclarativeAdminSettings.php',
'OCA\\Files\\Settings\\AdminSettings' => $baseDir . '/../lib/Settings/AdminSettings.php',
'OCA\\Files\\Settings\\PersonalSettings' => $baseDir . '/../lib/Settings/PersonalSettings.php',
);
4 changes: 3 additions & 1 deletion apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\BackgroundJob\\CleanupFileLocks' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupFileLocks.php',
'OCA\\Files\\BackgroundJob\\DeleteExpiredOpenLocalEditor' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php',
'OCA\\Files\\BackgroundJob\\DeleteOrphanedItems' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteOrphanedItems.php',
'OCA\\Files\\BackgroundJob\\SanitizeFilenames' => __DIR__ . '/..' . '/../lib/BackgroundJob/SanitizeFilenames.php',
'OCA\\Files\\BackgroundJob\\ScanFiles' => __DIR__ . '/..' . '/../lib/BackgroundJob/ScanFiles.php',
'OCA\\Files\\BackgroundJob\\TransferOwnership' => __DIR__ . '/..' . '/../lib/BackgroundJob/TransferOwnership.php',
'OCA\\Files\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
Expand Down Expand Up @@ -68,6 +69,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Controller\\ConversionApiController' => __DIR__ . '/..' . '/../lib/Controller/ConversionApiController.php',
'OCA\\Files\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php',
'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php',
'OCA\\Files\\Controller\\FilenamesController' => __DIR__ . '/..' . '/../lib/Controller/FilenamesController.php',
'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php',
'OCA\\Files\\Controller\\TemplateController' => __DIR__ . '/..' . '/../lib/Controller/TemplateController.php',
'OCA\\Files\\Controller\\TransferOwnershipController' => __DIR__ . '/..' . '/../lib/Controller/TransferOwnershipController.php',
Expand Down Expand Up @@ -103,7 +105,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Service\\TagService' => __DIR__ . '/..' . '/../lib/Service/TagService.php',
'OCA\\Files\\Service\\UserConfig' => __DIR__ . '/..' . '/../lib/Service/UserConfig.php',
'OCA\\Files\\Service\\ViewConfig' => __DIR__ . '/..' . '/../lib/Service/ViewConfig.php',
'OCA\\Files\\Settings\\DeclarativeAdminSettings' => __DIR__ . '/..' . '/../lib/Settings/DeclarativeAdminSettings.php',
'OCA\\Files\\Settings\\AdminSettings' => __DIR__ . '/..' . '/../lib/Settings/AdminSettings.php',
'OCA\\Files\\Settings\\PersonalSettings' => __DIR__ . '/..' . '/../lib/Settings/PersonalSettings.php',
);

Expand Down
3 changes: 0 additions & 3 deletions apps/files/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCA\Files\Settings\DeclarativeAdminSettings;
use OCP\Activity\IManager as IActivityManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
Expand Down Expand Up @@ -111,8 +110,6 @@ public function register(IRegistrationContext $context): void {
$context->registerCapability(AdvancedCapabilities::class);
$context->registerCapability(DirectEditingCapabilities::class);

$context->registerDeclarativeSettings(DeclarativeAdminSettings::class);

$context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class);
$context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class);
$context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class);
Expand Down
224 changes: 224 additions & 0 deletions apps/files/lib/BackgroundJob/SanitizeFilenames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\BackgroundJob;

use OC\Files\SetupManager;
use OCA\Files\AppInfo\Application;
use OCA\Files\Service\SettingsService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\QueuedJob;
use OCP\Config\IUserConfig;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IFilenameValidator;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;

class SanitizeFilenames extends QueuedJob {

private int $offset;
private int $limit;
private int $currentIndex;
private ?string $charReplacement = null;

public function __construct(
ITimeFactory $time,
private IJobList $jobList,
private IUserSession $session,
private IUserManager $manager,
private IAppConfig $appConfig,
private IUserConfig $userConfig,
private IRootFolder $rootFolder,
private SetupManager $setupManager,
private IFilenameValidator $filenameValidator,
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->setAllowParallelRuns(false);
}

/**
* Makes the background job do its work
*
* @param array $argument unused argument
* @throws \Exception
*/
public function run($argument) {
$this->charReplacement = strval($argument['charReplacement']) ?: null;
if (isset($argument['errorsOnly'])) {
$this->retryFailedNodes();
return;
}

$this->offset = intval($argument['offset']);
$this->limit = intval($argument['limit']);
if ($this->offset === 0) {
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_RUNNING);
}

$this->currentIndex = 0;
foreach ($this->manager->getSeenUsers($this->offset) as $user) {
$this->sanitizeUserFiles($user);
$this->currentIndex++;
$this->appConfig->setAppValueInt('sanitize_filenames_index', $this->currentIndex);

if ($this->currentIndex === $this->limit) {
break;
}
}

if ($this->currentIndex === $this->limit) {
$this->offset += $this->limit;
$this->jobList->add(self::class, ['limit' => $this->limit, 'offset' => $this->offset, 'charReplacement' => $this->charReplacement]);
return;
}

// No index to process anymore, we are done
$this->appConfig->deleteAppValue('sanitize_filenames_index');

$hasErrors = !empty($this->userConfig->getValuesByUsers(Application::APP_ID, 'sanitize_filenames_errors'));
if ($hasErrors) {
$this->logger->info('Filename sanitization finished with errors. Retrying failed files in next background job run.');
$this->jobList->add(self::class, ['errorsOnly' => true, 'charReplacement' => $this->charReplacement]);
return;
}

// we are really done!
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
}

/**
* Retry to sanitize files that failed in the first run
*/
private function retryFailedNodes(): void {
$this->logger->debug('Retry sanitizing failed filename sanitization.');
$results = $this->userConfig->getValuesByUsers(Application::APP_ID, 'sanitize_filenames_errors');

$hasErrors = false;
foreach ($results as $userId => $errors) {
$user = $this->manager->get($userId);
if ($user === null) {
// user got deleted meanwhile, ignore
continue;
}

$hasErrors = $hasErrors || $this->retryFailedUserNodes($user, $errors);
$this->userConfig->deleteUserConfig($userId, Application::APP_ID, 'sanitize_filenames_errors');
}

if ($hasErrors) {
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_ERROR);
$this->logger->error('Retrying filename sanitization failed permanently.');
} else {
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
$this->logger->info('Retrying filename sanitization succeeded.');
}
}

private function retryFailedUserNodes(IUser $user, array $errors): bool {
$this->session->setVolatileActiveUser($user);
$folder = $this->rootFolder->getUserFolder($user->getUID());

$this->logger->debug("filename sanitization retry: started for user '{$user->getUID()}'");
$hasErrors = false;
foreach ($errors as $path) {
try {
$node = $folder->get($path);
$this->sanitizeNode($node);
} catch (NotFoundException) {
// file got deleted meanwhile, ignore
} catch (\Exception $error) {
$this->logger->error('filename sanitization failed when retried: ' . $path, ['exception' => $error]);
$hasErrors = true;
}
}

// tear down FS for user to make sure we do not run out of memory due to cached user FS
$this->setupManager->tearDown();

return $hasErrors;
}


private function sanitizeUserFiles(IUser $user): void {
// Set an active user so that event listeners can correctly work (e.g. files versions)
$this->session->setVolatileActiveUser($user);
$folder = $this->rootFolder->getUserFolder($user->getUID());

$this->logger->debug("filename sanitization: started for user '{$user->getUID()}'");
$errors = $this->sanitizeFolder($folder);

// tear down FS for user to make sure we do not run out of memory due to cached user FS
$this->setupManager->tearDown();

if (!empty($errors)) {
$this->userConfig->setValueArray($user->getUID(), 'files', 'sanitize_filenames_errors', $errors, true);
}
}

/**
* Sanitizes the filenames of all nodes in a folder
*
* @return list<string> list of nodes that could not be sanitized
*/
private function sanitizeFolder(Folder $folder): array {
$errors = [];
foreach ($folder->getDirectoryListing() as $node) {
try {
$this->sanitizeNode($node);
} catch (LockedException) {
$this->logger->debug('filename sanitization skipped: ' . $node->getPath() . ' (file is locked)');
$errors[] = $node->getPath();
} catch (\Exception $error) {
$this->logger->warning('filename sanitization failed: ' . $node->getPath(), ['exception' => $error]);
$errors[] = $node->getPath();
}

if ($node instanceof Folder) {
$errors = array_merge($errors, $this->sanitizeFolder($node));
}
}
return $errors;
}

/**
* Sanitizes the filename of a single node
*
* @throws LockedException If the file is locked
* @throws \Exception Unknown error
*/
private function sanitizeNode(Node $node): void {
if ($node->isShared() && !$node->isUpdateable()) {
// we cannot rename files in shares where we do not have permissions - we do it when sanitizing the owner's files
return;
}

try {
$oldName = $node->getName();
$newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
if ($oldName !== $newName) {
$newName = $node->getParent()->getNonExistingName($newName);
$path = rtrim(dirname($node->getPath()), '/');

$node->move("$path/$newName");
}
} catch (NotFoundException) {
// file got deleted meanwhile, ignore
// or this is shared without permissions to rename it, ignore (owner will rename it)
}
}
}
9 changes: 9 additions & 0 deletions apps/files/lib/Command/SanitizeFilenames.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use Exception;
use OC\Core\Command\Base;
use OC\Files\FilenameValidator;
use OCA\Files\Service\SettingsService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotPermittedException;
Expand All @@ -29,13 +31,16 @@ class SanitizeFilenames extends Base {
private OutputInterface $output;
private ?string $charReplacement;
private bool $dryRun;
private bool $errorsOrSkipped = false;

public function __construct(
private IUserManager $userManager,
private IRootFolder $rootFolder,
private IUserSession $session,
private IFactory $l10nFactory,
private FilenameValidator $filenameValidator,
private SettingsService $service,
private IAppConfig $appConfig,
) {
parent::__construct();
}
Expand Down Expand Up @@ -100,6 +105,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
} else {
$this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
if ($this->service->hasFilesWindowsSupport() && $this->appConfig->getAppValueInt('sanitize_filenames_status') === 0) {
// we are done - if this is for sanitizing all users for windows filename support then set this UI flag
$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
}
}
return self::SUCCESS;
}
Expand Down
Loading
Loading