diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-07-25 22:51:12 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-08-27 12:33:26 +0200 |
commit | df2baa4c1551999799cb1ca7cf2ccd89034cf684 (patch) | |
tree | 004872b2da1f4d93e97e2274bcc848b3184dc0a8 /apps | |
parent | 7340e85b0cf502131d357e265f1805eb4909b5dc (diff) | |
download | nextcloud-server-df2baa4c1551999799cb1ca7cf2ccd89034cf684.tar.gz nextcloud-server-df2baa4c1551999799cb1ca7cf2ccd89034cf684.zip |
fix(files): Correctly validate new node name
* Resolves https://github.com/nextcloud/server/issues/45409
This includes two fixes:
1. The name in the "new node" dialog is correctly selected (e.g. `file.txt` only `file` is selected by default), to allow quick naming
2. `@nextcloud/files` functions for filename validation are used, this allows to use new Nextcloud 30 capabilities (e.g. reserved names)
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 46 | ||||
-rw-r--r-- | apps/files/src/components/NewNodeDialog.vue | 309 | ||||
-rw-r--r-- | apps/files/src/utils/filenameValidity.ts | 41 |
3 files changed, 188 insertions, 208 deletions
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index c73972f3ead..307a9c78330 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -44,7 +44,7 @@ class="files-list__row-name-link" data-cy-files-list-row-name-link v-bind="linkTo.params"> - <!-- File name --> + <!-- Filename --> <span class="files-list__row-name-text"> <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> <span class="files-list__row-name-" v-text="basename" /> @@ -60,7 +60,7 @@ import type { PropType } from 'vue' import axios from '@nextcloud/axios' import { showError, showSuccess } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' -import { FileType, NodeStatus } from '@nextcloud/files' +import { FileType, InvalidFilenameError, InvalidFilenameErrorReason, NodeStatus, validateFilename } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import { isAxiosError} from 'axios' @@ -149,7 +149,7 @@ export default Vue.extend({ renameLabel() { const matchLabel: Record<FileType, string> = { - [FileType.File]: t('files', 'File name'), + [FileType.File]: t('files', 'Filename'), [FileType.Folder]: t('files', 'Folder name'), } return matchLabel[this.source.type] @@ -187,7 +187,7 @@ export default Vue.extend({ watch: { /** - * If renaming starts, select the file name + * If renaming starts, select the filename * in the input, without the extension. * @param renaming */ @@ -222,27 +222,35 @@ export default Vue.extend({ input.reportValidity() } }, - isFileNameValid(name) { - const trimmedName = name.trim() - const char = trimmedName.indexOf('/') !== -1 - ? '/' - : forbiddenCharacters.find((char) => trimmedName.includes(char)) - - if (trimmedName === '.' || trimmedName === '..') { - throw new Error(t('files', '"{name}" is an invalid file name.', { name })) - } else if (trimmedName.length === 0) { + isFileNameValid(name: string) { + if (name.trim() === '') { throw new Error(t('files', 'File name cannot be empty.')) - } else if (char) { - throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char })) - } else if (trimmedName.match(OC.config.blacklist_files_regex)) { - throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) } else if (this.checkIfNodeExists(name)) { throw new Error(t('files', '{newName} already exists.', { newName: name })) } - return true + try { + validateFilename(name) + } catch (error) { + if (!(error instanceof InvalidFilenameError)) { + logger.error(error as Error) + return + } + switch (error.reason) { + case InvalidFilenameErrorReason.Character: + throw new Error(t('files', '"{segment}" is not allowed inside a filename.', { segment: error.segment })) + case InvalidFilenameErrorReason.ReservedName: + throw new Error(t('files', '"{segment}" is a forbidden file or folder name.', { segment: error.segment })) + case InvalidFilenameErrorReason.Extension: + if (error.segment.startsWith('.')) { + throw new Error(t('files', '"{segment}" is not an allowed filetype.', { segment: error.segment })) + } else { + throw new Error(t('files', 'Filenames must not end with "{segment}".', { segment: error.segment })) + } + } + } }, - checkIfNodeExists(name) { + checkIfNodeExists(name: string) { return this.nodes.find(node => node.basename === name && node !== this.source) }, diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index 0f6a739d21c..b4647724de3 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -1,226 +1,157 @@ <!-- - - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - - - - @author Ferdinand Thiessen <opensource@fthiessen.de> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcDialog :name="name" + <NcDialog data-cy-files-new-node-dialog + :name="name" :open="open" close-on-click-outside out-transition - @update:open="onClose"> + @update:open="emit('close', null)"> <template #actions> - <NcButton type="primary" - :disabled="!isUniqueName" - @click="onCreate"> + <NcButton data-cy-files-new-node-dialog-submit + type="primary" + :disabled="validity !== ''" + @click="submit"> {{ t('files', 'Create') }} </NcButton> </template> - <form @submit.prevent="onCreate"> - <NcTextField ref="input" - class="dialog__input" - :error="!isUniqueName" - :helper-text="errorMessage" + <form ref="formElement" + class="new-node-dialog__form" + @submit.prevent="emit('close', localDefaultName)"> + <NcTextField ref="nameInput" + data-cy-files-new-node-dialog-input + :error="validity !== ''" + :helper-text="validity" :label="label" - :value.sync="localDefaultName" - @keyup="checkInputValidity" /> + :value.sync="localDefaultName" /> </form> </NcDialog> </template> -<script lang="ts"> -import type { PropType } from 'vue' - -import { defineComponent } from 'vue' -import { translate as t } from '@nextcloud/l10n' +<script setup lang="ts"> +import type { ComponentPublicInstance, PropType } from 'vue' import { getUniqueName } from '@nextcloud/files' -import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { extname } from 'path' +import { nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { getFilenameValidity } from '../utils/filenameValidity.ts' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import logger from '../logger.js' - -interface ICanFocus { - focus: () => void -} -const forbiddenCharacters = loadState<string>('files', 'forbiddenCharacters', '').split('') - -export default defineComponent({ - name: 'NewNodeDialog', - components: { - NcButton, - NcDialog, - NcTextField, +const props = defineProps({ + /** + * The name to be used by default + */ + defaultName: { + type: String, + default: t('files', 'New folder'), }, - props: { - /** - * The name to be used by default - */ - defaultName: { - type: String, - default: t('files', 'New folder'), - }, - /** - * Other files that are in the current directory - */ - otherNames: { - type: Array as PropType<string[]>, - default: () => [], - }, - /** - * Open state of the dialog - */ - open: { - type: Boolean, - default: true, - }, - /** - * Dialog name - */ - name: { - type: String, - default: t('files', 'Create new folder'), - }, - /** - * Input label - */ - label: { - type: String, - default: t('files', 'Folder name'), - }, + /** + * Other files that are in the current directory + */ + otherNames: { + type: Array as PropType<string[]>, + default: () => [], }, - emits: { - close: (name: string|null) => name === null || name, + /** + * Open state of the dialog + */ + open: { + type: Boolean, + default: true, }, - data() { - return { - localDefaultName: this.defaultName || t('files', 'New folder'), - } + /** + * Dialog name + */ + name: { + type: String, + default: t('files', 'Create new folder'), }, - computed: { - errorMessage() { - if (this.isUniqueName) { - return '' - } else { - return t('files', 'A file or folder with that name already exists.') - } - }, - uniqueName() { - return getUniqueName(this.localDefaultName, this.otherNames) - }, - isUniqueName() { - return this.localDefaultName === this.uniqueName - }, + /** + * Input label + */ + label: { + type: String, + default: t('files', 'Folder name'), }, - watch: { - defaultName() { - this.localDefaultName = this.defaultName || t('files', 'New folder') - }, +}) - /** - * Ensure the input is focussed even if the dialog is already mounted but not open - */ - open() { - this.$nextTick(() => this.focusInput()) - }, - }, - mounted() { - // on mounted lets use the unique name - this.localDefaultName = this.uniqueName - this.$nextTick(() => this.focusInput()) - }, - methods: { - t, +const emit = defineEmits<{ + (event: 'close', name: string | null): void +}>() + +const localDefaultName = ref<string>(props.defaultName) +const nameInput = ref<ComponentPublicInstance>() +const formElement = ref<HTMLFormElement>() +const validity = ref('') + +/** + * Focus the filename input field + */ +function focusInput() { + nextTick(() => { + // get the input element + const input = nameInput.value?.$el.querySelector('input') + if (!props.open || !input) { + return + } - /** - * Focus the filename input field - */ - focusInput() { - if (this.open) { - this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.()) - } - }, + // length of the basename + const length = localDefaultName.value.length - extname(localDefaultName.value).length + // focus the input + input.focus() + // and set the selection to the basename (name without extension) + input.setSelectionRange(0, length) + }) +} - onCreate() { - this.$emit('close', this.localDefaultName) - }, - onClose(state: boolean) { - if (!state) { - this.$emit('close', null) - } - }, +/** + * Trigger submit on the form + */ +function submit() { + formElement.value?.requestSubmit() +} - /** - * Check if the file name is valid and update the - * input validity using browser's native validation. - * @param event the keyup event - */ - checkInputValidity(event: KeyboardEvent) { - const input = event.target as HTMLInputElement - const newName = this.localDefaultName.trim?.() || '' - logger.debug('Checking input validity', { newName }) - try { - this.isFileNameValid(newName) - input.setCustomValidity('') - input.title = '' - } catch (e) { - if (e instanceof Error) { - input.setCustomValidity(e.message) - input.title = e.message - } else { - input.setCustomValidity(t('files', 'Invalid file name')) - } - } finally { - input.reportValidity() - } - }, +// Reset local name on props change +watch(() => props.defaultName, () => { + localDefaultName.value = getUniqueName(props.defaultName, props.otherNames) +}) - isFileNameValid(name: string) { - const trimmedName = name.trim() - const char = trimmedName.indexOf('/') !== -1 - ? '/' - : forbiddenCharacters.find((char) => trimmedName.includes(char)) +// Validate the local name +watchEffect(() => { + if (props.otherNames.includes(localDefaultName.value)) { + validity.value = t('files', 'This name is already in use.') + } else { + validity.value = getFilenameValidity(localDefaultName.value) + } + const input = nameInput.value?.$el.querySelector('input') + if (input) { + input.setCustomValidity(validity.value) + input.reportValidity() + } +}) - if (trimmedName === '.' || trimmedName === '..') { - throw new Error(t('files', '"{name}" is an invalid file name.', { name })) - } else if (trimmedName.length === 0) { - throw new Error(t('files', 'File name cannot be empty.')) - } else if (char) { - throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char })) - } else if (trimmedName.match(window.OC.config.blacklist_files_regex)) { - throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) - } +// Ensure the input is focussed even if the dialog is already mounted but not open +watch(() => props.open, () => { + nextTick(() => { + focusInput() + }) +}) - return true - }, - }, +onMounted(() => { + // on mounted lets use the unique name + localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames) + nextTick(() => focusInput()) }) </script> -<style lang="scss" scoped> -.dialog__input { - :deep(input:invalid) { - // Show red border on invalid input - border-color: var(--color-error); - color: red; - } +<style scoped> +.new-node-dialog__form { + /* Ensure the dialog does not jump when there is a validity error */ + min-height: calc(3 * var(--default-clickable-area)); } </style> diff --git a/apps/files/src/utils/filenameValidity.ts b/apps/files/src/utils/filenameValidity.ts new file mode 100644 index 00000000000..2666d530052 --- /dev/null +++ b/apps/files/src/utils/filenameValidity.ts @@ -0,0 +1,41 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +/** + * Get the validity of a filename (empty if valid). + * This can be used for `setCustomValidity` on input elements + * @param name The filename + * @param escape Escape the matched string in the error (only set when used in HTML) + */ +export function getFilenameValidity(name: string, escape = false): string { + if (name.trim() === '') { + return t('files', 'Filename must not be empty.') + } + + try { + validateFilename(name) + return '' + } catch (error) { + if (!(error instanceof InvalidFilenameError)) { + throw error + } + + switch (error.reason) { + case InvalidFilenameErrorReason.Character: + return t('files', '"{char}" is not allowed inside a filename.', { char: error.segment }, undefined, { escape }) + case InvalidFilenameErrorReason.ReservedName: + return t('files', '"{segment}" is a reserved name and not allowed for filenames.', { segment: error.segment }, undefined, { escape: false }) + case InvalidFilenameErrorReason.Extension: + if (error.segment.match(/\.[a-z]/i)) { + return t('files', '"{extension}" is not an allowed filetype.', { extension: error.segment }, undefined, { escape: false }) + } + return t('files', 'Filenames must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false }) + default: + return t('files', 'Invalid filename.') + } + } +} |