feat(files): provide UI to sanitize filenames after enabling WCF
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>pull/54722/head
parent
4fe0799d26
commit
805fe3e15b
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
namespace OCA\Files\Controller;
|
||||||
|
|
||||||
|
use OCA\Files\BackgroundJob\SanitizeFilenames;
|
||||||
|
use OCA\Files\Service\SettingsService;
|
||||||
|
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||||
|
use OCP\AppFramework\Http\Attribute\Route;
|
||||||
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
|
use OCP\AppFramework\OCS\OCSBadRequestException;
|
||||||
|
use OCP\AppFramework\OCSController;
|
||||||
|
use OCP\AppFramework\Services\IAppConfig;
|
||||||
|
use OCP\BackgroundJob\IJobList;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IRequest;
|
||||||
|
use OCP\IUserManager;
|
||||||
|
|
||||||
|
class FilenamesController extends OCSController {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $appName,
|
||||||
|
IRequest $request,
|
||||||
|
private IL10N $l10n,
|
||||||
|
private IJobList $jobList,
|
||||||
|
private IAppConfig $appConfig,
|
||||||
|
private IUserManager $userManager,
|
||||||
|
private SettingsService $settingsService,
|
||||||
|
) {
|
||||||
|
parent::__construct($appName, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the Windows filename support feature.
|
||||||
|
*
|
||||||
|
* @param bool $enabled - The new state of the Windows filename support
|
||||||
|
* @return DataResponse
|
||||||
|
*/
|
||||||
|
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||||
|
#[Route(type: Route::TYPE_API, verb: 'POST', url: '/api/v1/filenames/windows-compatibility')]
|
||||||
|
public function toggleWindowFilenameSupport(bool $enabled): DataResponse {
|
||||||
|
$this->settingsService->setFilesWindowsSupport($enabled);
|
||||||
|
return new DataResponse(['enabled' => $enabled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a filename sanitization job
|
||||||
|
*
|
||||||
|
* @param null|int $limit Limit the number of users to be sanitized per run
|
||||||
|
* @param null|string $charReplacement Optionally specify a character to replace forbidden characters with
|
||||||
|
* @return DataResponse
|
||||||
|
* @throws OCSBadRequestException On invalid parameters or if a sanitization is already running
|
||||||
|
*/
|
||||||
|
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||||
|
#[Route(type: Route::TYPE_API, verb: 'POST', url: '/api/v1/filenames/sanitization')]
|
||||||
|
public function sanitizeFilenames(?int $limit = 10, ?string $charReplacement = null): DataResponse {
|
||||||
|
if ($limit < 1) {
|
||||||
|
throw new OCSBadRequestException($this->l10n->t('Limit must be a positive integer.'));
|
||||||
|
}
|
||||||
|
if ($charReplacement !== null && ($charReplacement === '' || mb_strlen($charReplacement) > 1)) {
|
||||||
|
throw new OCSBadRequestException($this->l10n->t('The replacement character may only be a single character.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->settingsService->isFilenameSanitizationRunning()) {
|
||||||
|
throw new OCSBadRequestException($this->l10n->t('Filename sanitization already started.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jobList->add(SanitizeFilenames::class, [
|
||||||
|
'offset' => 0,
|
||||||
|
'limit' => $limit,
|
||||||
|
'charReplacement' => $charReplacement,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new DataResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||||
|
#[Route(type: Route::TYPE_API, verb: 'GET', url: '/api/v1/filenames/sanitization')]
|
||||||
|
public function getStatus(): DataResponse {
|
||||||
|
return new DataResponse($this->settingsService->getSanitizationStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DataResponse
|
||||||
|
* @throws OCSBadRequestException If there is no filename sanitization in progress
|
||||||
|
*/
|
||||||
|
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||||
|
#[Route(type: Route::TYPE_API, verb: 'DELETE', url: '/api/v1/filenames/sanitization')]
|
||||||
|
public function stopSanitization(): DataResponse {
|
||||||
|
if (!$this->settingsService->isFilenameSanitizationRunning()) {
|
||||||
|
throw new OCSBadRequestException($this->l10n->t('No filename sanitization inprogress.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jobList->remove(SanitizeFilenames::class);
|
||||||
|
return new DataResponse([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
namespace OCA\Files\Settings;
|
||||||
|
|
||||||
|
use OCA\Files\AppInfo\Application;
|
||||||
|
use OCA\Files\Service\SettingsService;
|
||||||
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\AppFramework\Services\IInitialState;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\Settings\ISettings;
|
||||||
|
use OCP\Util;
|
||||||
|
|
||||||
|
class AdminSettings implements ISettings {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IL10N $l,
|
||||||
|
private SettingsService $service,
|
||||||
|
private IURLGenerator $urlGenerator,
|
||||||
|
private IInitialState $initialState,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSection(): string {
|
||||||
|
return 'server';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForm(): TemplateResponse {
|
||||||
|
$windowSupport = $this->service->hasFilesWindowsSupport();
|
||||||
|
$this->initialState->provideInitialState('filesCompatibilitySettings', [
|
||||||
|
'docUrl' => $this->urlGenerator->linkToDocs(''),
|
||||||
|
'status' => $this->service->getSanitizationStatus(),
|
||||||
|
'windowsSupport' => $windowSupport,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Util::addScript(Application::APP_ID, 'settings-admin');
|
||||||
|
return new TemplateResponse(Application::APP_ID, 'settings-admin', renderAs: TemplateResponse::RENDER_AS_BLANK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
|
|
||||||
class DeclarativeAdminSettings implements IDeclarativeSettingsFormWithHandlers {
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private IL10N $l,
|
|
||||||
private SettingsService $service,
|
|
||||||
private IURLGenerator $urlGenerator,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getValue(string $fieldId, IUser $user): mixed {
|
|
||||||
return match($fieldId) {
|
|
||||||
'windows_support' => $this->service->hasFilesWindowsSupport(),
|
|
||||||
default => throw new \InvalidArgumentException('Unexpected field id ' . $fieldId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setValue(string $fieldId, mixed $value, IUser $user): void {
|
|
||||||
switch ($fieldId) {
|
|
||||||
case 'windows_support':
|
|
||||||
$this->service->setFilesWindowsSupport((bool)$value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSchema(): array {
|
|
||||||
return [
|
|
||||||
'id' => 'files-filename-support',
|
|
||||||
'priority' => 10,
|
|
||||||
'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
|
|
||||||
'section_id' => 'server',
|
|
||||||
'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
|
|
||||||
'title' => $this->l->t('Files compatibility'),
|
|
||||||
'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' => [
|
|
||||||
[
|
|
||||||
'id' => 'windows_support',
|
|
||||||
'title' => $this->l->t('Enforce Windows compatibility'),
|
|
||||||
'description' => $this->l->t('This will block filenames not valid on Windows systems, like using reserved names or special characters. But this will not enforce compatibility of case sensitivity.'),
|
|
||||||
'type' => DeclarativeSettingsTypes::CHECKBOX,
|
|
||||||
'default' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||||
|
|
||||||
|
import axios, { isAxiosError } from '@nextcloud/axios'
|
||||||
|
import { showError } from '@nextcloud/dialogs'
|
||||||
|
import { loadState } from '@nextcloud/initial-state'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import { generateOcsUrl } from '@nextcloud/router'
|
||||||
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
|
import NcInputField from '@nextcloud/vue/components/NcInputField'
|
||||||
|
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
|
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
|
||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
import logger from '../../logger.ts'
|
||||||
|
|
||||||
|
type ApiStatus = { total: number, processed: number, errors?: Record<string, string[]>, status: 0 | 1 | 2 | 3 | 4 }
|
||||||
|
|
||||||
|
const { status: initialStatus } = loadState<{ isRunningSanitization: boolean, status: ApiStatus }>('files', 'filesCompatibilitySettings')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const renameLimit = ref(10)
|
||||||
|
const status = ref(initialStatus.status)
|
||||||
|
const processedUsers = ref(initialStatus.processed)
|
||||||
|
const totalUsers = ref(initialStatus.total)
|
||||||
|
const errors = shallowRef<ApiStatus['errors']>(initialStatus.errors || {})
|
||||||
|
|
||||||
|
const progress = computed(() => processedUsers.value > 0 ? Math.round((processedUsers.value * 100) / totalUsers.value) : 0)
|
||||||
|
const isRunning = computed(() => status.value === 1 || status.value === 2)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the sanitization process
|
||||||
|
*/
|
||||||
|
async function startSanitization() {
|
||||||
|
if (isRunning.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await axios.post(generateOcsUrl('apps/files/api/v1/filenames/sanitization'), {
|
||||||
|
limit: renameLimit.value,
|
||||||
|
})
|
||||||
|
status.value = 1
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start filename sanitization.', { error })
|
||||||
|
|
||||||
|
if (isAxiosError(error) && error.response?.data?.ocs) {
|
||||||
|
showError((error.response.data as OCSResponse).ocs.meta.message!)
|
||||||
|
} else {
|
||||||
|
showError(t('files', 'Failed to start filename sanitization.'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the filename sanitization status
|
||||||
|
*/
|
||||||
|
async function refreshStatus() {
|
||||||
|
if (loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await axios.get<OCSResponse<ApiStatus>>(generateOcsUrl('apps/files/api/v1/filenames/sanitization'))
|
||||||
|
status.value = data.ocs.data.status
|
||||||
|
totalUsers.value = data.ocs.data.total
|
||||||
|
processedUsers.value = data.ocs.data.processed
|
||||||
|
errors.value = data.ocs.data.errors || {}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to refresh filename sanitization status.', { error })
|
||||||
|
showError(t('files', 'Failed to refresh filename sanitization status.'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NcNoteCard v-if="isRunning">
|
||||||
|
<div class="sanitize-filenames__progress-container">
|
||||||
|
<p>
|
||||||
|
{{ t('files', 'Filename sanitization in progress.') }}
|
||||||
|
<br>
|
||||||
|
<template v-if="processedUsers > 0">
|
||||||
|
{{ t('files', 'Currently {processedUsers} of {totalUsers} accounts are already processed.', { processedUsers, totalUsers }) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ t('files', 'Preparing …') }}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<NcProgressBar :value="progress" :size="12" />
|
||||||
|
<NcButton variant="tertiary" @click="refreshStatus">
|
||||||
|
<template v-if="loading" #icon>
|
||||||
|
<NcLoadingIcon />
|
||||||
|
</template>
|
||||||
|
{{ t('files', 'Refresh') }}
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<NcNoteCard v-else-if="status === 3" type="success">
|
||||||
|
{{ t('files', 'All files have been santized for Windows filename support.') }}
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<form v-else
|
||||||
|
class="sanitize-filenames__form"
|
||||||
|
:disabled="loading"
|
||||||
|
@submit.stop.prevent="startSanitization">
|
||||||
|
<NcNoteCard v-if="status === 4" type="error">
|
||||||
|
{{ t('files', 'Some files could not be sanitized, please check your logs.') }}
|
||||||
|
<ul class="sanitize-filenames__errors" :aria-label="t('files', 'Sanitization errors')">
|
||||||
|
<li v-for="[user, failedFiles] of Object.entries(errors)" :key="user">
|
||||||
|
<h4>{{ user }}:</h4>
|
||||||
|
<ul :aria-label="t('files', 'Not sanitized filenames')">
|
||||||
|
<li v-for="file of failedFiles" :key="file">
|
||||||
|
{{ file }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</NcNoteCard>
|
||||||
|
<NcNoteCard>
|
||||||
|
{{ t('files', 'Windows filename support has been enabled.') }}
|
||||||
|
<br>
|
||||||
|
{{ t('files', 'While this blocks users from creating new files with unsupported filenames, existing files are not yet renamed and thus still may break sync on Windows.') }}
|
||||||
|
{{ t('files', 'You can trigger a rename of files with invalid filenames, this will be done in the background and may take some time.') }}
|
||||||
|
{{ t('files', 'Please note that this may cause high workload on the sync clients.') }}
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<fieldset class="sanitize-filenames__fields">
|
||||||
|
<NcInputField v-model="renameLimit"
|
||||||
|
:label="t('files', 'Limit')"
|
||||||
|
:helper-text="t('files', 'This allows to configure how many users should be processed in one background job run.')"
|
||||||
|
min="1"
|
||||||
|
type="number" />
|
||||||
|
|
||||||
|
<NcButton type="submit" variant="error">
|
||||||
|
<template v-if="loading" #icon>
|
||||||
|
<NcLoadingIcon />
|
||||||
|
</template>
|
||||||
|
{{ t('files', 'Sanitize filenames') }}
|
||||||
|
<span v-if="loading" class="hidden-visually">
|
||||||
|
{{ t('files', '(starting)') }}
|
||||||
|
</span>
|
||||||
|
</NcButton>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sanitize-filenames__progress-container {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-grid-baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sanitize-filenames__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-grid-baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sanitize-filenames__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-grid-baseline);
|
||||||
|
|
||||||
|
align-items: end;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCSPNonce } from '@nextcloud/auth'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import PersonalSettings from './views/SettingsAdmin.vue'
|
||||||
|
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
__webpack_nonce__ = getCSPNonce()
|
||||||
|
|
||||||
|
Vue.prototype.t = t
|
||||||
|
const View = Vue.extend(PersonalSettings)
|
||||||
|
const instance = new View()
|
||||||
|
instance.$mount('#files-admin-settings')
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { showError } from '@nextcloud/dialogs'
|
||||||
|
import { loadState } from '@nextcloud/initial-state'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import { generateOcsUrl } from '@nextcloud/router'
|
||||||
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import SettingsSanitizeFilenames from '../components/Settings/SettingsSanitizeFilenames.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import logger from '../logger'
|
||||||
|
|
||||||
|
const {
|
||||||
|
docUrl,
|
||||||
|
isRunningSanitization,
|
||||||
|
windowsSupport,
|
||||||
|
} = loadState<{ docUrl: string, isRunningSanitization: boolean, windowsSupport: boolean }>('files', 'filesCompatibilitySettings')
|
||||||
|
|
||||||
|
const description = t('files', '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' + t('files', 'After enabling the Windows compatible filenames, existing files cannot be modified anymore but can be renamed to valid new names by their owner.')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const hasWindowsSupport = ref(windowsSupport)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the Windows filename support on the backend.
|
||||||
|
*
|
||||||
|
* @param enabled - The new state to be set
|
||||||
|
*/
|
||||||
|
async function toggleWindowsFilenameSupport(enabled: boolean) {
|
||||||
|
if (loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await axios.post(generateOcsUrl('apps/files/api/v1/filenames/windows-compatibility'), { enabled })
|
||||||
|
hasWindowsSupport.value = enabled
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('files', 'Failed to toggle Windows filename support'))
|
||||||
|
logger.error('Failed to toggle Windows filename support', { error })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NcSettingsSection :doc-url="docUrl"
|
||||||
|
:name="t('files', 'Files compatibility')"
|
||||||
|
:description="description">
|
||||||
|
<NcCheckboxRadioSwitch :model-value="hasWindowsSupport"
|
||||||
|
:disabled="isRunningSanitization"
|
||||||
|
:loading="loading"
|
||||||
|
type="switch"
|
||||||
|
@update:model-value="toggleWindowsFilenameSupport">
|
||||||
|
{{ t('files', 'Enforce Windows compatibility') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<p class="hint">
|
||||||
|
{{ t('files', 'This will block filenames not valid on Windows systems, like using reserved names or special characters. But this will not enforce compatibility of case sensitivity.') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SettingsSanitizeFilenames v-if="hasWindowsSupport" />
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
color: var(--color-text-maxcontrast);
|
||||||
|
margin-inline-start: var(--border-radius-element);
|
||||||
|
margin-block-end: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div id="files-admin-settings"></div>
|
||||||
Loading…
Reference in New Issue