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