mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 01:30:50 -04:00
Merge pull request #51608 from nextcloud/feat/sanitize-filenames-command
feat(files): add command to automatically rename filenames
This commit is contained in:
commit
c668703021
7 changed files with 302 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
168
apps/files/lib/Command/SanitizeFilenames.php
Normal file
168
apps/files/lib/Command/SanitizeFilenames.php
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
52
apps/files/lib/Command/WindowsCompatibleFilenames.php
Normal file
52
apps/files/lib/Command/WindowsCompatibleFilenames.php
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => [
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue