diff options
-rw-r--r-- | apps/files/appinfo/info.xml | 2 | ||||
-rw-r--r-- | apps/files/composer/composer/autoload_classmap.php | 2 | ||||
-rw-r--r-- | apps/files/composer/composer/autoload_static.php | 2 | ||||
-rw-r--r-- | apps/files/lib/Command/SanitizeFilenames.php | 168 | ||||
-rw-r--r-- | apps/files/lib/Command/WindowsCompatibleFilenames.php | 52 | ||||
-rw-r--r-- | apps/files/lib/Settings/DeclarativeAdminSettings.php | 9 | ||||
-rw-r--r-- | build/integration/files_features/windows_compatibility.feature | 68 |
7 files changed, 302 insertions, 1 deletions
diff --git a/apps/files/appinfo/info.xml b/apps/files/appinfo/info.xml index 95f6153e1d7..aedcd5b7ed5 100644 --- a/apps/files/appinfo/info.xml +++ b/apps/files/appinfo/info.xml @@ -46,12 +46,14 @@ <command>OCA\Files\Command\Delete</command> <command>OCA\Files\Command\Copy</command> <command>OCA\Files\Command\Move</command> + <command>OCA\Files\Command\SanitizeFilenames</command> <command>OCA\Files\Command\Object\Delete</command> <command>OCA\Files\Command\Object\Get</command> <command>OCA\Files\Command\Object\Put</command> <command>OCA\Files\Command\Object\Info</command> <command>OCA\Files\Command\Object\ListObject</command> <command>OCA\Files\Command\Object\Orphans</command> + <command>OCA\Files\Command\WindowsCompatibleFilenames</command> </commands> <settings> diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index a74df7ba3d2..070cb46de38 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -42,9 +42,11 @@ return array( 'OCA\\Files\\Command\\Object\\Put' => $baseDir . '/../lib/Command/Object/Put.php', 'OCA\\Files\\Command\\Put' => $baseDir . '/../lib/Command/Put.php', 'OCA\\Files\\Command\\RepairTree' => $baseDir . '/../lib/Command/RepairTree.php', + 'OCA\\Files\\Command\\SanitizeFilenames' => $baseDir . '/../lib/Command/SanitizeFilenames.php', 'OCA\\Files\\Command\\Scan' => $baseDir . '/../lib/Command/Scan.php', 'OCA\\Files\\Command\\ScanAppData' => $baseDir . '/../lib/Command/ScanAppData.php', 'OCA\\Files\\Command\\TransferOwnership' => $baseDir . '/../lib/Command/TransferOwnership.php', + 'OCA\\Files\\Command\\WindowsCompatibleFilenames' => $baseDir . '/../lib/Command/WindowsCompatibleFilenames.php', 'OCA\\Files\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php', 'OCA\\Files\\Controller\\ConversionApiController' => $baseDir . '/../lib/Controller/ConversionApiController.php', 'OCA\\Files\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 1d79f38e35a..ce79d370e7c 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -57,9 +57,11 @@ class ComposerStaticInitFiles 'OCA\\Files\\Command\\Object\\Put' => __DIR__ . '/..' . '/../lib/Command/Object/Put.php', 'OCA\\Files\\Command\\Put' => __DIR__ . '/..' . '/../lib/Command/Put.php', 'OCA\\Files\\Command\\RepairTree' => __DIR__ . '/..' . '/../lib/Command/RepairTree.php', + 'OCA\\Files\\Command\\SanitizeFilenames' => __DIR__ . '/..' . '/../lib/Command/SanitizeFilenames.php', 'OCA\\Files\\Command\\Scan' => __DIR__ . '/..' . '/../lib/Command/Scan.php', 'OCA\\Files\\Command\\ScanAppData' => __DIR__ . '/..' . '/../lib/Command/ScanAppData.php', 'OCA\\Files\\Command\\TransferOwnership' => __DIR__ . '/..' . '/../lib/Command/TransferOwnership.php', + 'OCA\\Files\\Command\\WindowsCompatibleFilenames' => __DIR__ . '/..' . '/../lib/Command/WindowsCompatibleFilenames.php', 'OCA\\Files\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php', 'OCA\\Files\\Controller\\ConversionApiController' => __DIR__ . '/..' . '/../lib/Controller/ConversionApiController.php', 'OCA\\Files\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php', diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php new file mode 100644 index 00000000000..ea01afd20d6 --- /dev/null +++ b/apps/files/lib/Command/SanitizeFilenames.php @@ -0,0 +1,168 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Command; + +use Exception; +use OC\Core\Command\Base; +use OC\Files\FilenameValidator; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Lock\LockedException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class SanitizeFilenames extends Base { + + private OutputInterface $output; + private string $charReplacement; + private bool $dryRun; + + public function __construct( + private IUserManager $userManager, + private IRootFolder $rootFolder, + private IUserSession $session, + private IFactory $l10nFactory, + private FilenameValidator $filenameValidator, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $forbiddenCharacter = $this->filenameValidator->getForbiddenCharacters(); + $charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacter); + $charReplacement = reset($charReplacement) ?: ''; + + $this + ->setName('files:sanitize-filenames') + ->setDescription('Renames files to match naming constraints') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'will only rename files the given user(s) have access to' + ) + ->addOption( + 'dry-run', + mode: InputOption::VALUE_NONE, + description: 'Do not actually rename any files but just check filenames.', + ) + ->addOption( + 'char-replacement', + 'c', + mode: InputOption::VALUE_REQUIRED, + description: 'Replacement for invalid character (by default space, underscore or dash is used)', + default: $charReplacement, + ); + + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->charReplacement = $input->getOption('char-replacement'); + if ($this->charReplacement === '' || mb_strlen($this->charReplacement) > 1) { + $output->writeln('<error>No character replacement given</error>'); + return 1; + } + + $this->dryRun = $input->getOption('dry-run'); + if ($this->dryRun) { + $output->writeln('<info>Dry run is enabled, no actual renaming will be applied.</>'); + } + + $this->output = $output; + $users = $input->getArgument('user_id'); + if (!empty($users)) { + foreach ($users as $userId) { + $user = $this->userManager->get($userId); + if ($user === null) { + $output->writeln("<error>User '$userId' does not exist - skipping</>"); + continue; + } + $this->sanitizeUserFiles($user); + } + } else { + $this->userManager->callForSeenUsers($this->sanitizeUserFiles(...)); + } + return self::SUCCESS; + } + + 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); + + $this->output->writeln('<info>Analyzing files of ' . $user->getUID() . '</>'); + + $folder = $this->rootFolder->getUserFolder($user->getUID()); + $this->sanitizeFiles($folder); + } + + private function sanitizeFiles(Folder $folder): void { + foreach ($folder->getDirectoryListing() as $node) { + $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE); + + try { + $oldName = $node->getName(); + if (!$this->filenameValidator->isFilenameValid($oldName)) { + $newName = $this->sanitizeName($oldName); + $newName = $folder->getNonExistingName($newName); + $path = rtrim(dirname($node->getPath()), '/'); + + if (!$this->dryRun) { + $node->move("$path/$newName"); + } elseif (!$folder->isCreatable()) { + // simulate error for dry run + throw new NotPermittedException(); + } + $this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"'); + } + } catch (LockedException) { + $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (file is locked)</>'); + } catch (NotPermittedException) { + $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (no permissions)</>'); + } catch (Exception) { + $this->output->writeln('<error>failed: ' . $node->getPath() . '</>'); + } + + if ($node instanceof Folder) { + $this->sanitizeFiles($node); + } + } + } + + private function sanitizeName(string $name): string { + $l10n = $this->l10nFactory->get('files'); + + foreach ($this->filenameValidator->getForbiddenExtensions() as $extension) { + if (str_ends_with($name, $extension)) { + $name = substr($name, 0, strlen($name) - strlen($extension)); + } + } + + $basename = substr($name, 0, strpos($name, '.', 1) ?: null); + if (in_array($basename, $this->filenameValidator->getForbiddenBasenames())) { + $name = str_replace($basename, $l10n->t('%1$s (renamed)', [$basename]), $name); + } + + if ($name === '') { + $name = $l10n->t('renamed file'); + } + + $forbiddenCharacter = $this->filenameValidator->getForbiddenCharacters(); + $name = str_replace($forbiddenCharacter, $this->charReplacement, $name); + + return $name; + } +} diff --git a/apps/files/lib/Command/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php new file mode 100644 index 00000000000..84a1b277824 --- /dev/null +++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Command; + +use OC\Core\Command\Base; +use OCA\Files\Service\SettingsService; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class WindowsCompatibleFilenames extends Base { + + public function __construct( + private SettingsService $service, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $this + ->setName('files:windows-compatible-filenames') + ->setDescription('Enforce naming constraints for windows compatible filenames') + ->addOption('enable', description: 'Enable windows naming constraints') + ->addOption('disable', description: 'Disable windows naming constraints'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('enable')) { + if ($this->service->hasFilesWindowsSupport()) { + $output->writeln('<error>Windows compatible filenames already enforced.</error>', OutputInterface::VERBOSITY_VERBOSE); + } + $this->service->setFilesWindowsSupport(true); + $output->writeln('Windows compatible filenames enforced.'); + } elseif ($input->getOption('disable')) { + if (!$this->service->hasFilesWindowsSupport()) { + $output->writeln('<error>Windows compatible filenames already disabled.</error>', OutputInterface::VERBOSITY_VERBOSE); + } + $this->service->setFilesWindowsSupport(false); + $output->writeln('Windows compatible filename constraints removed.'); + } else { + $output->writeln('Windows compatible filenames are ' . ($this->service->hasFilesWindowsSupport() ? 'enforced' : 'disabled')); + } + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Settings/DeclarativeAdminSettings.php b/apps/files/lib/Settings/DeclarativeAdminSettings.php index e509ad2233b..2f363f05958 100644 --- a/apps/files/lib/Settings/DeclarativeAdminSettings.php +++ b/apps/files/lib/Settings/DeclarativeAdminSettings.php @@ -9,6 +9,7 @@ namespace OCA\Files\Settings; use OCA\Files\Service\SettingsService; use OCP\IL10N; +use OCP\IURLGenerator; use OCP\IUser; use OCP\Settings\DeclarativeSettingsTypes; use OCP\Settings\IDeclarativeSettingsFormWithHandlers; @@ -18,6 +19,7 @@ class DeclarativeAdminSettings implements IDeclarativeSettingsFormWithHandlers { public function __construct( private IL10N $l, private SettingsService $service, + private IURLGenerator $urlGenerator, ) { } @@ -44,7 +46,12 @@ class DeclarativeAdminSettings implements IDeclarativeSettingsFormWithHandlers { 'section_id' => 'server', 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL, 'title' => $this->l->t('Files compatibility'), - 'description' => $this->l->t('Allow to restrict filenames to ensure files can be synced with all clients. By default all filenames valid on POSIX (e.g. Linux or macOS) are allowed.'), + 'doc_url' => $this->urlGenerator->linkToDocs('admin-windows-compatible-filenames'), + 'description' => ( + $this->l->t('Allow to restrict filenames to ensure files can be synced with all clients. By default all filenames valid on POSIX (e.g. Linux or macOS) are allowed.') + . "\n" . $this->l->t('After enabling the windows compatible filenames, existing files cannot be modified anymore but can be renamed to valid new names by their owner.') + . "\n" . $this->l->t('It is also possible to migrate files automatically after enabling this setting, please refer to the documentation about the occ command.') + ), 'fields' => [ [ diff --git a/build/integration/files_features/windows_compatibility.feature b/build/integration/files_features/windows_compatibility.feature new file mode 100644 index 00000000000..2fdd37d67a1 --- /dev/null +++ b/build/integration/files_features/windows_compatibility.feature @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +Feature: Windows compatible filenames + Background: + Given using api version "1" + And using new dav path + And As an "admin" + + Scenario: prevent upload files with invalid name + Given As an "admin" + And user "user0" exists + And invoking occ with "files:windows-compatible-filenames --enable" + Given User "user0" created a folder "/com1" + Then as "user0" the file "/com1" does not exist + + Scenario: renaming a folder with invalid name + Given As an "admin" + When invoking occ with "files:windows-compatible-filenames --disable" + And user "user0" exists + Given User "user0" created a folder "/aux" + When invoking occ with "files:windows-compatible-filenames --enable" + And invoking occ with "files:sanitize-filenames user0" + Then as "user0" the file "/aux" does not exist + And as "user0" the file "/aux (renamed)" exists + + Scenario: renaming a file with invalid base name + Given As an "admin" + When invoking occ with "files:windows-compatible-filenames --disable" + And user "user0" exists + When User "user0" uploads file with content "hello" to "/com0.txt" + And invoking occ with "files:windows-compatible-filenames --enable" + And invoking occ with "files:sanitize-filenames user0" + Then as "user0" the file "/com0.txt" does not exist + And as "user0" the file "/com0 (renamed).txt" exists + + Scenario: renaming a file with invalid extension + Given As an "admin" + When invoking occ with "files:windows-compatible-filenames --disable" + And user "user0" exists + When User "user0" uploads file with content "hello" to "/foo.txt." + And as "user0" the file "/foo.txt." exists + And invoking occ with "files:windows-compatible-filenames --enable" + And invoking occ with "files:sanitize-filenames user0" + Then as "user0" the file "/foo.txt." does not exist + And as "user0" the file "/foo.txt" exists + + Scenario: renaming a file with invalid character + Given As an "admin" + When invoking occ with "files:windows-compatible-filenames --disable" + And user "user0" exists + When User "user0" uploads file with content "hello" to "/2*2=4.txt" + And as "user0" the file "/2*2=4.txt" exists + And invoking occ with "files:windows-compatible-filenames --enable" + And invoking occ with "files:sanitize-filenames user0" + Then as "user0" the file "/2*2=4.txt" does not exist + And as "user0" the file "/2 2=4.txt" exists + + Scenario: renaming a file with invalid character and replacement setup + Given As an "admin" + When invoking occ with "files:windows-compatible-filenames --disable" + And user "user0" exists + When User "user0" uploads file with content "hello" to "/2*3=6.txt" + And as "user0" the file "/2*3=6.txt" exists + And invoking occ with "files:windows-compatible-filenames --enable" + And invoking occ with "files:sanitize-filenames --char-replacement + user0" + Then as "user0" the file "/2*3=6.txt" does not exist + And as "user0" the file "/2+3=6.txt" exists |