feat(files): add command to automatically rename filenames

Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Co-authored-by: Côme Chilliet <91878298+come-nc@users.noreply.github.com>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/51608/head
Ferdinand Thiessen 7 months ago
parent 535253e0d2
commit 226ad23a1a
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
  1. 1
      apps/files/appinfo/info.xml
  2. 1
      apps/files/composer/composer/autoload_classmap.php
  3. 1
      apps/files/composer/composer/autoload_static.php
  4. 168
      apps/files/lib/Command/SanitizeFilenames.php
  5. 68
      build/integration/files_features/windows_compatibility.feature

@ -46,6 +46,7 @@
<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>

@ -42,6 +42,7 @@ 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',

@ -57,6 +57,7 @@ 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',

@ -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;
}
}

@ -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…
Cancel
Save