mirror of
https://github.com/nextcloud/server.git
synced 2026-06-09 00:32:29 -04:00
Merge pull request #46753 from nextcloud/fix/renaming-full-validation
fix(files): Use `@nextcloud/files` filename validation to show more details
This commit is contained in:
commit
ba91f42c8b
14 changed files with 94 additions and 85 deletions
|
|
@ -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>
|
||||
|
||||
|
|
@ -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',
|
||||
|
||||
|
|
@ -187,55 +185,30 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
},
|
||||
|
||||
newName() {
|
||||
// Check validity of the new name
|
||||
const newName = this.newName.trim?.() || ''
|
||||
const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
let validity = getFilenameValidity(newName)
|
||||
// Checking if already exists
|
||||
if (validity === '' && this.checkIfNodeExists(newName)) {
|
||||
validity = t('files', 'Another entry with the same name already exists.')
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.isRenaming) {
|
||||
input.setCustomValidity(validity)
|
||||
input.reportValidity()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
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
|
||||
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()
|
||||
}
|
||||
},
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
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 newName = this.newName.trim?.() || ''
|
||||
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
|
||||
const newName = this.newName.trim?.() || ''
|
||||
if (newName === '') {
|
||||
showError(t('files', 'Name cannot be empty'))
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
41
apps/files/src/utils/filenameValidity.ts
Normal file
41
apps/files/src/utils/filenameValidity.ts
Normal file
|
|
@ -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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-files_sharing_tab.js
vendored
4
dist/files_sharing-files_sharing_tab.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-files_sharing_tab.js.map
vendored
2
dist/files_sharing-files_sharing_tab.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue