]> source.dussan.org Git - nextcloud-server.git/commitdiff
fix(files): Use `@nextcloud/files` filename validation to show more details
authorFerdinand Thiessen <opensource@fthiessen.de>
Thu, 25 Jul 2024 12:29:31 +0000 (14:29 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Thu, 25 Jul 2024 13:49:33 +0000 (15:49 +0200)
This will enable showing more details what exactly is wrong with the filename.
Especially with the new capabilities introduced with Nextcloud 30.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/files/src/components/FileEntry/FileEntryName.vue
apps/files/src/utils/filenameValidity.ts [new file with mode: 0644]

index 875c0892a726c2a04ae241e2071805817430c206..be491db016f5c24d4e713e9ef7b29f3fce53fa39 100644 (file)
@@ -5,6 +5,7 @@
 <template>
        <!-- Rename input -->
        <form v-if="isRenaming"
+               ref="renameForm"
                v-on-click-outside="onRename"
                :aria-label="t('files', 'Rename file')"
                class="files-list__row-rename"
@@ -16,7 +17,6 @@
                        :required="true"
                        :value.sync="newName"
                        enterkeyhint="done"
-                       @keyup="checkInputValidity"
                        @keyup.esc="stopRenaming" />
        </form>
 
 import type { Node } from '@nextcloud/files'
 import type { PropType } from 'vue'
 
+import axios, { isAxiosError } from '@nextcloud/axios'
 import { showError, showSuccess } from '@nextcloud/dialogs'
 import { emit } from '@nextcloud/event-bus'
 import { FileType, NodeStatus, Permission } from '@nextcloud/files'
-import { loadState } from '@nextcloud/initial-state'
 import { translate as t } from '@nextcloud/l10n'
-import axios, { isAxiosError } from '@nextcloud/axios'
 import { defineComponent } from 'vue'
 
 import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
 
 import { useNavigation } from '../../composables/useNavigation'
 import { useRenamingStore } from '../../store/renaming.ts'
+import { getFilenameValidity } from '../../utils/filenameValidity.ts'
 import logger from '../../logger.js'
 
-const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
-
 export default defineComponent({
        name: 'FileEntryName',
 
@@ -187,55 +185,30 @@ export default defineComponent({
                                }
                        },
                },
-       },
 
-       methods: {
-               /**
-                * 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
+               newName() {
+                       // Check validity of the new name
                        const newName = this.newName.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()
+                       const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+                       if (!input) {
+                               return
                        }
-               },
 
-               isFileNameValid(name: string) {
-                       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) {
-                               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 }))
-                       } else if (this.checkIfNodeExists(name)) {
-                               throw new Error(t('files', '{newName} already exists.', { newName: name }))
+                       let validity = getFilenameValidity(newName)
+                       // Checking if already exists
+                       if (validity === '' && this.checkIfNodeExists(newName)) {
+                               validity = t('files', 'Another entry with the same name already exists.')
                        }
-
-                       return true
+                       this.$nextTick(() => {
+                               if (this.isRenaming) {
+                                       input.setCustomValidity(validity)
+                                       input.reportValidity()
+                               }
+                       })
                },
+       },
 
+       methods: {
                checkIfNodeExists(name: string) {
                        return this.nodes.find(node => node.basename === name && node !== this.source)
                },
@@ -243,20 +216,20 @@ export default defineComponent({
                startRenaming() {
                        this.$nextTick(() => {
                                // Using split to get the true string length
-                               const extLength = (this.source.extension || '').split('').length
-                               const length = this.source.basename.split('').length - extLength
-                               const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
+                               const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
                                if (!input) {
                                        logger.error('Could not find the rename input')
                                        return
                                }
-                               input.setSelectionRange(0, length)
                                input.focus()
+                               const length = this.source.basename.length - (this.source.extension ?? '').length
+                               input.setSelectionRange(0, length)
 
                                // Trigger a keyup event to update the input validity
                                input.dispatchEvent(new Event('keyup'))
                        })
                },
+
                stopRenaming() {
                        if (!this.isRenaming) {
                                return
@@ -268,25 +241,20 @@ export default defineComponent({
 
                // Rename and move the file
                async onRename() {
-                       const oldName = this.source.basename
-                       const oldEncodedSource = this.source.encodedSource
                        const newName = this.newName.trim?.() || ''
-                       if (newName === '') {
-                               showError(t('files', 'Name cannot be empty'))
+                       const form = this.$refs.renameForm as HTMLFormElement
+                       if (!form.checkValidity()) {
+                               showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
                                return
                        }
 
+                       const oldName = this.source.basename
+                       const oldEncodedSource = this.source.encodedSource
                        if (oldName === newName) {
                                this.stopRenaming()
                                return
                        }
 
-                       // Checking if already exists
-                       if (this.checkIfNodeExists(newName)) {
-                               showError(t('files', 'Another entry with the same name already exists'))
-                               return
-                       }
-
                        // Set loading state
                        this.$set(this.source, 'status', NodeStatus.LOADING)
 
diff --git a/apps/files/src/utils/filenameValidity.ts b/apps/files/src/utils/filenameValidity.ts
new file mode 100644 (file)
index 0000000..2666d53
--- /dev/null
@@ -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.')
+               }
+       }
+}