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
parent
535253e0d2
commit
226ad23a1a
@ -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…
Reference in new issue