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