Skip to content

Commit

Permalink
fix(files): Use @nextcloud/files filename validation to show more d…
Browse files Browse the repository at this point in the history
…etails

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>
  • Loading branch information
susnux committed Jul 25, 2024
1 parent e989feb commit e7edbb8
Show file tree
Hide file tree
Showing 2 changed files with 69 additions and 61 deletions.
90 changes: 29 additions & 61 deletions apps/files/src/components/FileEntry/FileEntryName.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -16,7 +17,6 @@
:required="true"
:value.sync="newName"
enterkeyhint="done"
@keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>

Expand All @@ -40,22 +40,20 @@
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',

Expand Down Expand Up @@ -187,76 +185,51 @@ 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)
},

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
Expand All @@ -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)

Expand Down
40 changes: 40 additions & 0 deletions apps/files/src/utils/filenameValidity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*!
* 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
*/
export function getFilenameValidity(name: string): 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 })
case InvalidFilenameErrorReason.ReservedName:
return t('files', '"{segment}" is a reserved name and not allowed for filenames.', { segment: error.segment })
case InvalidFilenameErrorReason.Extension:
if (error.segment.match(/\.[a-z]/i)) {
return t('files', '"{extension}" is not an allowed filetype.', { extension: error.segment })
}
return t('files', 'Filenames must not end with "{extension}".', { extension: error.segment })
default:
return t('files', 'Invalid filename.')
}
}
}

0 comments on commit e7edbb8

Please sign in to comment.