From 4418e6df19efdaaa00a4cd44242b38509c8ff2f9 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 12 Aug 2025 14:01:34 +0700 Subject: [PATCH] Chore/align bucket and object naming validation with storage api (#37854) * Align bucket naming validation * Align folder naming validation * Remove description --- .../interfaces/Storage/CreateBucketModal.tsx | 67 +++++++++++-------- .../Storage/CreateBucketModal.utils.ts | 15 +++++ .../FileExplorerRowEditing.tsx | 22 +++++- apps/studio/state/storage-explorer.tsx | 59 ++++++++++------ 4 files changed, 113 insertions(+), 50 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/CreateBucketModal.utils.ts diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 172d6a75754..b02df4a2a4e 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -39,6 +39,7 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils' export interface CreateBucketModalProps { @@ -46,29 +47,42 @@ export interface CreateBucketModalProps { onClose: () => void } -const FormSchema = z.object({ - name: z - .string() - .trim() - .min(1, 'Please provide a name for your bucket') - .regex( - /^[a-z0-9.-]+$/, - 'The name of the bucket must only contain lowercase letters, numbers, dots, and hyphens' - ) - .refine((value) => !value.endsWith(' '), 'The name of the bucket cannot end with a whitespace') - .refine( - (value) => value !== 'public', - '"public" is a reserved name. Please choose another name' - ), - type: z.enum(['STANDARD', 'ANALYTICS']).default('STANDARD'), - public: z.boolean().default(false), - has_file_size_limit: z.boolean().default(false), - formatted_size_limit: z.coerce - .number() - .min(0, 'File size upload limit has to be at least 0') - .default(0), - allowed_mime_types: z.string().trim().default(''), -}) +const FormSchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'Please provide a name for your bucket') + .max(100, 'Bucket name should be below 100 characters') + .refine( + (value) => !value.endsWith(' '), + 'The name of the bucket cannot end with a whitespace' + ) + .refine( + (value) => value !== 'public', + '"public" is a reserved name. Please choose another name' + ), + type: z.enum(['STANDARD', 'ANALYTICS']).default('STANDARD'), + public: z.boolean().default(false), + has_file_size_limit: z.boolean().default(false), + formatted_size_limit: z.coerce + .number() + .min(0, 'File size upload limit has to be at least 0') + .default(0), + allowed_mime_types: z.string().trim().default(''), + }) + .superRefine((data, ctx) => { + if (!validBucketNameRegex.test(data.name)) { + const [match] = data.name.match(inverseValidBucketNameRegex) ?? [] + ctx.addIssue({ + path: ['name'], + code: z.ZodIssueCode.custom, + message: !!match + ? `Bucket name cannot contain the "${match}" character` + : 'Bucket name contains an invalid special character', + }) + } + }) export type CreateBucketForm = z.infer @@ -182,10 +196,9 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { name="name" render={({ field }) => ( @@ -194,12 +207,12 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { )} /> -
+
( - + { - const { renameFile, renameFolder, addNewFolder } = useStorageExplorerStateSnapshot() + const { renameFile, renameFolder, addNewFolder, updateRowStatus } = + useStorageExplorerStateSnapshot() const inputRef = useRef(null) const [itemName, setItemName] = useState(item.name) @@ -28,7 +29,22 @@ const FileExplorerRowEditing = ({ item, view, columnIndex }: FileExplorerRowEdit await renameFile(item, name, columnIndex) } else if (has(item, 'id')) { const itemWithColumnIndex = { ...item, columnIndex } - renameFolder(itemWithColumnIndex, name, columnIndex) + renameFolder({ + folder: itemWithColumnIndex, + newName: name, + columnIndex, + onError: () => { + if (event.type === 'blur') { + updateRowStatus({ + name: itemWithColumnIndex.name, + status: STORAGE_ROW_STATUS.READY, + columnIndex, + }) + } else { + inputRef.current.select() + } + }, + }) } else { addNewFolder({ folderName: name, diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 67e27994112..4e9db42662d 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -8,6 +8,10 @@ import { proxy, useSnapshot } from 'valtio' import { createClient, SupabaseClient } from '@supabase/supabase-js' import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js' import { LOCAL_STORAGE_KEYS } from 'common' +import { + inverseValidObjectKeyRegex, + validObjectKeyRegex, +} from 'components/interfaces/Storage/CreateBucketModal.utils' import { STORAGE_BUCKET_SORT, STORAGE_ROW_STATUS, @@ -260,6 +264,17 @@ function createStorageExplorerState({ .join('/') }, + validateFolderName: (name: string) => { + if (!validObjectKeyRegex.test(name)) { + const [match] = name.match(inverseValidObjectKeyRegex) ?? [] + return !!match + ? `Folder name cannot contain the "${match}" character` + : 'Folder name contains an invalid special character' + } + + return null + }, + addNewFolderPlaceholder: (columnIndex: number) => { const isPrepend = true const folderName = 'Untitled folder' @@ -292,22 +307,22 @@ function createStorageExplorerState({ autofix, columnIndex, }) + if (formattedName === null) { onError?.() return } - if (!/^[a-zA-Z0-9_-\s]*$/.test(formattedName)) { - onError?.() - return toast.error( - 'Only alphanumeric characters, hyphens, and underscores are allowed for folder names.' - ) - } - if (formattedName.length === 0) { return state.removeTempRows(columnIndex) } + const folderNameError = state.validateFolderName(formattedName) + if (folderNameError) { + onError?.() + return toast.error(folderNameError) + } + state.updateFolderAfterEdit({ folderName: formattedName, columnIndex }) const emptyPlaceholderFile = `${formattedName}/${EMPTY_FOLDER_PLACEHOLDER_FILE_NAME}` @@ -602,7 +617,17 @@ function createStorageExplorerState({ } }, - renameFolder: async (folder: StorageItemWithColumn, newName: string, columnIndex: number) => { + renameFolder: async ({ + folder, + newName, + columnIndex, + onError, + }: { + folder: StorageItemWithColumn + newName: string + columnIndex: number + onError?: () => void + }) => { const originalName = folder.name if (originalName === newName) { return state.updateRowStatus({ @@ -612,24 +637,18 @@ function createStorageExplorerState({ }) } + const folderNameError = state.validateFolderName(newName) + if (folderNameError) { + onError?.() + return toast.error(folderNameError) + } + const toastId = toast( , { closeButton: false, position: 'top-right' } ) try { - /** - * Catch any folder names that contain slash or backslash - * - * this is because slashes are used to denote - * children/parent relationships in bucket - * - * todo: move this to a util file, as createFolder() uses same logic - */ - if (newName.includes('/') || newName.includes('\\')) { - return toast.error(`Folder name cannot contain forward or back slashes.`) - } - state.updateRowStatus({ name: originalName, status: STORAGE_ROW_STATUS.LOADING,