From e712454ec20a8f6b5abb2fb7bc66ed8481303c3f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:37:41 +0200 Subject: [PATCH] fix: store awkward file names and explain file errors on Stalwart 1.0 Stalwart 1.0 checks names more strictly than its capability says: a reserved name may not be the stem either (`con.txt`, `Aux.tar.gz`), and control characters are refused (macOS `Icon\r`). Names over 255 bytes of UTF-8 were never accepted. Uploaded names are now made acceptable before they are sent: control characters become "_", a reserved stem gets "_" after it (`con.txt` becomes `con_.txt`), and a long name is shortened, keeping its extension. The rename and new-folder dialogs refuse such names with their own messages. The rules apply wherever the server publishes naming rules, so 0.16.6 to 0.16.23 get `con_.txt` too. One refused file used to abort a whole upload. Uploads now go on and report the files that failed at the end, and a folder upload skips the contents of a folder it could not create instead of dropping them into the folder the upload started in. What would sit deeper than 1.0's `maxFileNodeDepth` (64) is left out before it is uploaded, and a folder copy that would go past it fails before the first create. File errors keep their JMAP type, and the Files toasts now say why an action failed: too many files or folders (`overQuota`), folders nested too deeply, a refused name, missing rights, or a file too large. New and copied files get a bare lowercase media type, which 1.0 requires. --- components/files/file-browser.tsx | 7 +- components/files/files-app.tsx | 83 +++++++++----- lib/__tests__/file-errors.test.ts | 42 +++++++ lib/__tests__/file-name-rules.test.ts | 117 +++++++++++++++++-- lib/__tests__/jmap-filenode-writes.test.ts | 124 ++++++++++++++++++++- lib/__tests__/media-type.test.ts | 28 +++++ lib/file-errors.ts | 40 +++++++ lib/file-name-rules.ts | 115 ++++++++++++++++--- lib/jmap/client-interface.ts | 2 + lib/jmap/client.ts | 73 +++++++++--- lib/jmap/filenode-depth.ts | 18 +++ lib/media-type.ts | 22 ++++ locales/ar/common.json | 8 ++ locales/ca/common.json | 8 ++ locales/cs/common.json | 8 ++ locales/da/common.json | 8 ++ locales/de/common.json | 8 ++ locales/en/common.json | 8 ++ locales/es/common.json | 8 ++ locales/fa/common.json | 8 ++ locales/fr/common.json | 8 ++ locales/he/common.json | 8 ++ locales/hu/common.json | 8 ++ locales/it/common.json | 8 ++ locales/ja/common.json | 8 ++ locales/ko/common.json | 8 ++ locales/lv/common.json | 8 ++ locales/mn/common.json | 8 ++ locales/nb/common.json | 8 ++ locales/nl/common.json | 8 ++ locales/pl/common.json | 8 ++ locales/pt/common.json | 8 ++ locales/ro/common.json | 8 ++ locales/ru/common.json | 8 ++ locales/sk/common.json | 8 ++ locales/tr/common.json | 8 ++ locales/uk/common.json | 8 ++ locales/zh-TW/common.json | 8 ++ locales/zh/common.json | 8 ++ stores/__tests__/file-store.test.ts | 108 ++++++++++++++++++ stores/file-store.ts | 91 +++++++++++---- 41 files changed, 991 insertions(+), 95 deletions(-) create mode 100644 lib/__tests__/file-errors.test.ts create mode 100644 lib/__tests__/media-type.test.ts create mode 100644 lib/file-errors.ts create mode 100644 lib/jmap/filenode-depth.ts create mode 100644 lib/media-type.ts diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index b9cdcb867..cf6fa01c0 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -416,7 +416,12 @@ export function FileBrowser({ const validateFileName = useCallback((name: string) => { const problem = fileNameProblem(name, fileNameRules); if (!problem) return null; - return problem.kind === "chars" ? t("name_forbidden_chars", { chars: problem.chars }) : t("name_reserved"); + switch (problem.kind) { + case "chars": return t("name_forbidden_chars", { chars: problem.chars }); + case "control": return t("name_control_chars"); + case "tooLong": return t("name_too_long"); + case "reserved": return t("name_reserved"); + } }, [fileNameRules, t]); const [showNewFolder, setShowNewFolder] = useState(false); const [renameTarget, setRenameTarget] = useState(null); diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 745610f85..199b91e26 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -10,7 +10,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, resourceServerRef } from "@/stores/file-store"; +import { useFileStore, resourceServerRef, type UploadReport } from "@/stores/file-store"; +import { fileErrorKind } from "@/lib/file-errors"; import { toast } from "@/stores/toast-store"; import { cn, formatFileSize } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -307,15 +308,41 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } }, [navigate, navigateByPath, isEmbedded]); + // What the server said went wrong, as the second line under an action's + // own error message; undefined when it said nothing specific. + const failureReason = useCallback((err: unknown): string | undefined => { + switch (fileErrorKind(err)) { + case "overQuota": return t("error_over_quota"); + case "tooDeep": return t("error_too_deep"); + case "invalidName": return t("error_invalid_name"); + case "forbidden": return t("error_forbidden"); + case "tooLarge": return t("error_too_large"); + default: return undefined; + } + }, [t]); + + // An upload goes on past a file the server refuses; the refused ones are + // reported once it is done. + const showUploadReport = useCallback((report: UploadReport) => { + if (report.uploaded > 0) { + toast.success(t("upload_success", { count: report.uploaded })); + } + if (report.failed.length === 0) return; + const names = report.failed.slice(0, 3).map((f) => f.name).join(", ") + + (report.failed.length > 3 ? ", …" : ""); + const reason = failureReason(report.failed[0].error); + toast.error(t("upload_failed", { count: report.failed.length }), reason ? `${names}: ${reason}` : names); + }, [t, failureReason]); + const handleCreateFolder = useCallback(async (name: string) => { try { await createDirectory(name); toast.success(t("create_folder_success")); } catch (err) { console.error("Failed to create folder:", err); - toast.error(t("create_folder_error")); + toast.error(t("create_folder_error"), failureReason(err)); } - }, [createDirectory, t]); + }, [createDirectory, t, failureReason]); const maxSizeUpload = client?.getMaxSizeUpload() || 0; @@ -329,13 +356,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - await uploadFiles(files); - toast.success(t("upload_success", { count: files.length })); + showUploadReport(await uploadFiles(files)); } catch (err) { console.error("Failed to upload files:", err); - toast.error(t("upload_error")); + toast.error(t("upload_error"), failureReason(err)); } - }, [uploadFiles, t, maxSizeUpload]); + }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]); const handleUploadFolder = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { @@ -347,13 +373,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - await uploadFolder(files); - toast.success(t("upload_success", { count: files.length })); + showUploadReport(await uploadFolder(files)); } catch (err) { console.error("Failed to upload folder:", err); - toast.error(t("upload_error")); + toast.error(t("upload_error"), failureReason(err)); } - }, [uploadFolder, t, maxSizeUpload]); + }, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]); const handleDelete = useCallback(async (name: string) => { const confirmed = await confirmDialog({ @@ -369,9 +394,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { toast.success(t("delete_success")); } catch (err) { console.error("Failed to delete:", err); - toast.error(t("delete_error")); + toast.error(t("delete_error"), failureReason(err)); } - }, [deleteResource, confirmDialog, t]); + }, [deleteResource, confirmDialog, t, failureReason]); const handleBatchDelete = useCallback(async (names: string[]) => { const confirmed = await confirmDialog({ @@ -387,9 +412,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { toast.success(t("batch_delete_success", { count: names.length })); } catch (err) { console.error("Failed to batch delete:", err); - toast.error(t("delete_error")); + toast.error(t("delete_error"), failureReason(err)); } - }, [deleteResources, confirmDialog, t]); + }, [deleteResources, confirmDialog, t, failureReason]); const handleUndo = useCallback(async () => { try { @@ -397,9 +422,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { toast.success(t("undo_success")); } catch (err) { console.error("Failed to undo:", err); - toast.error(t("undo_error")); + toast.error(t("undo_error"), failureReason(err)); } - }, [undoLastAction, t]); + }, [undoLastAction, t, failureReason]); const handleRename = useCallback(async (oldName: string, newName: string) => { try { @@ -409,9 +434,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }); } catch (err) { console.error("Failed to rename:", err); - toast.error(t("rename_error")); + toast.error(t("rename_error"), failureReason(err)); } - }, [renameResource, t, handleUndo]); + }, [renameResource, t, handleUndo, failureReason]); const findResourceId = useCallback((name: string) => { const r = resources.find(res => res.name === name); @@ -443,9 +468,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { toast.success(t("create_file_success")); } catch (err) { console.error("Failed to create file:", err); - toast.error(t("create_file_error")); + toast.error(t("create_file_error"), failureReason(err)); } - }, [createTextFile, t]); + }, [createTextFile, t, failureReason]); const handleDuplicate = useCallback(async (name: string) => { try { @@ -453,9 +478,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { toast.success(t("duplicate_success")); } catch (err) { console.error("Failed to duplicate:", err); - toast.error(t("duplicate_error")); + toast.error(t("duplicate_error"), failureReason(err)); } - }, [duplicateResource, t]); + }, [duplicateResource, t, failureReason]); const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => { try { @@ -465,9 +490,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }); } catch (err) { console.error("Failed to move:", err); - toast.error(t("move_error")); + toast.error(t("move_error"), failureReason(err)); } - }, [moveToFolder, t, handleUndo]); + }, [moveToFolder, t, handleUndo, failureReason]); const handleMoveToParent = useCallback(async (names: string[]) => { try { @@ -477,9 +502,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }); } catch (err) { console.error("Failed to move:", err); - toast.error(t("move_error")); + toast.error(t("move_error"), failureReason(err)); } - }, [moveToParent, t, handleUndo]); + }, [moveToParent, t, handleUndo, failureReason]); const handlePaste = useCallback(async () => { try { @@ -489,9 +514,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }); } catch (err) { console.error("Failed to paste:", err); - toast.error(t("paste_error")); + toast.error(t("paste_error"), failureReason(err)); } - }, [pasteResources, t, lastAction, handleUndo]); + }, [pasteResources, t, lastAction, handleUndo, failureReason]); const handlePreviewImage = useCallback((name: string) => { setPreviewImage(name); diff --git a/lib/__tests__/file-errors.test.ts b/lib/__tests__/file-errors.test.ts new file mode 100644 index 000000000..0d52aa891 --- /dev/null +++ b/lib/__tests__/file-errors.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { fileErrorKind, folderDepthError } from '../file-errors'; +import { jmapError } from '../jmap/jmap-error'; + +// SetErrors as Stalwart words them (1.0: jmap/src/file/*.rs; 0.16.23: +// jmap/src/file/set.rs), thrown by the JMAP client through jmapError. +const setError = (type: string, description: string, properties?: string[]) => + jmapError(description, { type, description, properties }); + +describe('fileErrorKind', () => { + it('explains Stalwart 1.0\'s limits on files and folders', () => { + expect(fileErrorKind(setError('overQuota', 'There are too many files, please delete some before adding a new one.'))).toBe('overQuota'); + expect(fileErrorKind(setError('overQuota', 'There are too many folders, please delete some before adding a new one.'))).toBe('overQuota'); + expect(fileErrorKind(setError('invalidProperties', 'Maximum folder depth exceeded.', ['parentId']))).toBe('tooDeep'); + expect(fileErrorKind(setError('tooLarge', 'File is too large.'))).toBe('tooLarge'); + }); + + it('treats Bulwark\'s own depth check like the server\'s', () => { + expect(fileErrorKind(folderDepthError())).toBe('tooDeep'); + }); + + it('explains refused names on 0.16 and 1.0', () => { + expect(fileErrorKind(setError('invalidProperties', 'Name is reserved and cannot be used.', ['name']))).toBe('invalidName'); + expect(fileErrorKind(setError('invalidProperties', 'Name contains a forbidden character.', ['name']))).toBe('invalidName'); + expect(fileErrorKind(setError('invalidProperties', 'Name must be between 1 and 255 octets.', ['name']))).toBe('invalidName'); + }); + + it('explains missing rights on 0.16 and 1.0', () => { + expect(fileErrorKind(setError('forbidden', 'You are not allowed to create file nodes in this folder.'))).toBe('forbidden'); + expect(fileErrorKind(setError('forbidden', 'You do not have access to blobId b2.'))).toBe('forbidden'); + expect(fileErrorKind(setError('forbidden', 'You are not allowed to modify this file node.'))).toBe('forbidden'); + }); + + it('has nothing to add for other errors', () => { + // The folder is gone, or a folder was moved into itself. + expect(fileErrorKind(setError('invalidProperties', 'Parent ID does not exist or is not a folder.', ['parentId']))).toBeNull(); + expect(fileErrorKind(setError('invalidProperties', 'Circular reference in parent ids.', ['parentId']))).toBeNull(); + expect(fileErrorKind(setError('notFound', 'Not found'))).toBeNull(); + expect(fileErrorKind(new Error('Failed to upload file: 500'))).toBeNull(); + expect(fileErrorKind(undefined)).toBeNull(); + }); +}); diff --git a/lib/__tests__/file-name-rules.test.ts b/lib/__tests__/file-name-rules.test.ts index 98314d397..32e58aaad 100644 --- a/lib/__tests__/file-name-rules.test.ts +++ b/lib/__tests__/file-name-rules.test.ts @@ -1,11 +1,26 @@ import { describe, it, expect } from 'vitest'; import { acceptedFileName, fileNameProblem, fileNameRulesFrom } from '../file-name-rules'; +const RESERVED = [ + '.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM0', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', + 'COM8', 'COM9', 'LPT0', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', +]; + // The filenode capability Stalwart 0.16.23 publishes. -const STALWART = fileNameRulesFrom({ +const STALWART_016 = fileNameRulesFrom({ + maxFileNodeDepth: null, maxSizeFileNodeName: 255, forbiddenNameChars: '/<>:"\\|?*', - forbiddenNodeNames: ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1'], + forbiddenNodeNames: RESERVED, +}); + +// Stalwart 1.0 publishes the same rules (plus a depth limit) but also refuses +// reserved stems and control characters, which the capability doesn't say. +const STALWART_10 = fileNameRulesFrom({ + maxFileNodeDepth: 64, + maxSizeFileNodeName: 255, + forbiddenNameChars: '/<>:"\\|?*', + forbiddenNodeNames: RESERVED, }); describe('file name rules', () => { @@ -15,16 +30,98 @@ describe('file name rules', () => { expect(acceptedFileName('a:b', null)).toBe('a:b'); }); - it('reports forbidden characters and reserved names', () => { - expect(fileNameProblem('bad:name?.txt', STALWART)).toEqual({ kind: 'chars', chars: ': ?' }); - expect(fileNameProblem('con', STALWART)).toEqual({ kind: 'reserved' }); - expect(fileNameProblem('CON.txt', STALWART)).toBeNull(); - expect(fileNameProblem('report.pdf', STALWART)).toBeNull(); + it('reads the byte limit from the capability', () => { + expect(STALWART_10?.maxNameBytes).toBe(255); + expect(fileNameRulesFrom({ forbiddenNameChars: '/', maxSizeFileNodeName: 100 })?.maxNameBytes).toBe(100); + expect(fileNameRulesFrom({ forbiddenNameChars: '/' })?.maxNameBytes).toBe(255); + }); + + it('reports forbidden characters and reserved names on 0.16', () => { + expect(fileNameProblem('bad:name?.txt', STALWART_016)).toEqual({ kind: 'chars', chars: ': ?' }); + expect(fileNameProblem('con', STALWART_016)).toEqual({ kind: 'reserved' }); + expect(fileNameProblem('report.pdf', STALWART_016)).toBeNull(); + }); + + // The cases of Stalwart 1.0's own `reserved_names` test (jmap/src/file/node.rs). + it('refuses reserved names and reserved stems the way Stalwart 1.0 does', () => { + for (const name of ['CON', 'con.txt', 'Aux.tar.gz', '..', '.', 'LPT9.log']) { + expect(fileNameProblem(name, STALWART_10), name).toEqual({ kind: 'reserved' }); + } + for (const name of ['console.txt', '.bashrc', 'auxiliary', 'com10', 'a.con', 'ok name (1).txt']) { + expect(fileNameProblem(name, STALWART_10), name).toBeNull(); + } + }); + + it('applies the stem rule on 0.16 too, as 1.0 would refuse the name later', () => { + expect(fileNameProblem('CON.txt', STALWART_016)).toEqual({ kind: 'reserved' }); + }); + + it('refuses control characters (Rust char::is_control) as Stalwart 1.0 does', () => { + expect(fileNameProblem('a\u0001b', STALWART_10)).toEqual({ kind: 'control' }); + // macOS stores custom folder icons as "Icon\r". + expect(fileNameProblem('Icon\r', STALWART_10)).toEqual({ kind: 'control' }); + expect(fileNameProblem('a\u007fb', STALWART_10)).toEqual({ kind: 'control' }); + expect(fileNameProblem('next\u0085line', STALWART_10)).toEqual({ kind: 'control' }); + // Not a control character. + expect(fileNameProblem('no break', STALWART_10)).toBeNull(); + }); + + it('refuses names over 255 bytes of UTF-8, as every Stalwart release does', () => { + expect(fileNameProblem('a'.repeat(256), STALWART_10)).toEqual({ kind: 'tooLong' }); + expect(fileNameProblem('a'.repeat(255), STALWART_10)).toBeNull(); + // 128 x 2 bytes. + expect(fileNameProblem('é'.repeat(128), STALWART_016)).toEqual({ kind: 'tooLong' }); + expect(fileNameProblem('é'.repeat(127), STALWART_016)).toBeNull(); }); it('turns an uploaded name into one the server accepts', () => { - expect(acceptedFileName('bad:name?.txt', STALWART)).toBe('bad_name_.txt'); - expect(acceptedFileName('NUL', STALWART)).toBe('NUL_'); - expect(acceptedFileName('fine.txt', STALWART)).toBe('fine.txt'); + expect(acceptedFileName('bad:name?.txt', STALWART_10)).toBe('bad_name_.txt'); + expect(acceptedFileName('NUL', STALWART_10)).toBe('NUL_'); + expect(acceptedFileName('fine.txt', STALWART_10)).toBe('fine.txt'); + }); + + it('puts the "_" after a reserved stem, where Stalwart 1.0 looks', () => { + expect(acceptedFileName('con.txt', STALWART_10)).toBe('con_.txt'); + expect(acceptedFileName('Aux.tar.gz', STALWART_10)).toBe('Aux_.tar.gz'); + expect(acceptedFileName('LPT9.log', STALWART_10)).toBe('LPT9_.log'); + expect(acceptedFileName('..', STALWART_10)).toBe('.._'); + expect(acceptedFileName('.', STALWART_10)).toBe('._'); + for (const name of ['console.txt', '.bashrc', 'auxiliary', 'com10', 'a.con']) { + expect(acceptedFileName(name, STALWART_10), name).toBe(name); + } + }); + + it('replaces control characters', () => { + expect(acceptedFileName('Icon\r', STALWART_10)).toBe('Icon_'); + expect(acceptedFileName('a\u0085b.txt', STALWART_10)).toBe('a_b.txt'); + }); + + it('shortens a name to 255 bytes and keeps its extension', () => { + const long = acceptedFileName(`${'a'.repeat(300)}.txt`, STALWART_10); + expect(new TextEncoder().encode(long).length).toBe(255); + expect(long.endsWith('.txt')).toBe(true); + + // Whole characters only: a 2-byte "é" is not split. + const accented = acceptedFileName(`${'é'.repeat(200)}.pdf`, STALWART_10); + expect(accented).toBe(`${'é'.repeat(125)}.pdf`); + + // An "extension" too long to keep goes with the rest. + expect(acceptedFileName(`a.${'b'.repeat(300)}`, STALWART_10)).toBe(`a.${'b'.repeat(253)}`); + }); + + it('keeps a name at the byte limit acceptable when its stem is reserved', () => { + // 3 + 1 + 251 = 255 bytes: no room to add "_" after "con". + const accepted = acceptedFileName(`con.${'x'.repeat(251)}`, STALWART_10); + expect(accepted).toBe(`co_.${'x'.repeat(251)}`); + expect(fileNameProblem(accepted, STALWART_10)).toBeNull(); + }); + + it('only produces names the rules accept', () => { + const awkward = [ + 'con.txt', 'Icon\r', 'a:b|c', '..', `${'z'.repeat(400)}.docx`, `CON.${'y'.repeat(260)}`, 'tab\there', 'prn', + ]; + for (const name of awkward) { + expect(fileNameProblem(acceptedFileName(name, STALWART_10), STALWART_10), JSON.stringify(name)).toBeNull(); + } }); }); diff --git a/lib/__tests__/jmap-filenode-writes.test.ts b/lib/__tests__/jmap-filenode-writes.test.ts index 2fa88f62b..f8e7d9042 100644 --- a/lib/__tests__/jmap-filenode-writes.test.ts +++ b/lib/__tests__/jmap-filenode-writes.test.ts @@ -1,25 +1,44 @@ import { describe, it, expect, vi } from 'vitest'; import { JMAPClient } from '../jmap/client'; +import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error'; +import { fileErrorKind } from '../file-errors'; type Call = [string, Record, string]; interface FakeOptions { /** The SetError the server answers a create with, if it refuses it. */ refuseCreate?: (props: Record) => Record | undefined; + /** The SetError the server answers an update or destroy of `id` with. */ + refuse?: (id: string) => Record | undefined; + /** The filenode account capability of "me" (and of "grp", unless `sharedCapability` says otherwise). */ + capability?: Record; + /** Null: "grp" lists no filenode capability, as a shared account may. */ + sharedCapability?: Record | null; } +// The rules Stalwart 1.0 publishes (0.16.23: the same with maxFileNodeDepth null). +const STALWART_10_CAPABILITY = { + maxFileNodeDepth: 64, + maxSizeFileNodeName: 255, + forbiddenNameChars: '/<>:"\\|?*', + forbiddenNodeNames: ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1'], +}; + // A primary account "me" plus a shared drive "grp", as listed by // listAllFileNodesAcrossAccounts ("grp:"). function makeClient(nodes: Record>>, options: FakeOptions = {}) { const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); - const fileCap = { 'urn:ietf:params:jmap:filenode': {} }; + const fileCap = { 'urn:ietf:params:jmap:filenode': options.capability ?? {} }; + const sharedCap = options.sharedCapability === null + ? {} + : { 'urn:ietf:params:jmap:filenode': options.sharedCapability ?? options.capability ?? {} }; Object.assign(client, { accountId: 'me', capabilities: { 'urn:ietf:params:jmap:filenode': {} }, - accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: fileCap, isPersonal: false } }, + accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: sharedCap, isPersonal: false } }, session: { primaryAccounts: { 'urn:ietf:params:jmap:filenode': 'me' }, - accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: fileCap, isPersonal: false } }, + accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: sharedCap, isPersonal: false } }, }, }); vi.spyOn(client as unknown as { getFilesCapableAccountIds: () => string[] }, 'getFilesCapableAccountIds') @@ -43,10 +62,20 @@ function makeClient(nodes: Record>>, optio return { methodResponses: [['FileNode/set', { created: { n0: { id: `new${++seq}` } } }, id]] }; } if (method === 'FileNode/set' && args.update) { - return { methodResponses: [['FileNode/set', { updated: Object.fromEntries(Object.keys(args.update as object).map(k => [k, null])) }, id]] }; + const ids = Object.keys(args.update as object); + const refused = ids.filter(k => options.refuse?.(k)); + return { methodResponses: [['FileNode/set', { + updated: Object.fromEntries(ids.filter(k => !refused.includes(k)).map(k => [k, null])), + notUpdated: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])), + }, id]] }; } if (method === 'FileNode/set' && args.destroy) { - return { methodResponses: [['FileNode/set', { destroyed: args.destroy }, id]] }; + const ids = args.destroy as string[]; + const refused = ids.filter(k => options.refuse?.(k)); + return { methodResponses: [['FileNode/set', { + destroyed: ids.filter(k => !refused.includes(k)), + notDestroyed: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])), + }, id]] }; } throw new Error(`unexpected ${method}`); }); @@ -236,3 +265,88 @@ describe('FileNode writes', () => { expect(sets(calls).some(a => a.destroy)).toBe(false); }); }); + +describe('FileNode write errors, names and types', () => { + const caught = (promise: Promise) => promise.then(() => null, (error: unknown) => error); + + it('keeps the SetError\'s type and properties on what it throws on Stalwart 1.0', async () => { + const { client } = makeClient({}, { + refuseCreate: () => ({ type: 'invalidProperties', description: 'Maximum folder depth exceeded.', properties: ['parentId'] }), + refuse: (id) => id === 'n1' + ? { type: 'forbidden', description: 'You are not allowed to rename or move this file node.' } + : { type: 'overQuota', description: 'There are too many folders, please delete some before adding a new one.' }, + }); + + const tooDeep = await caught(client.createFileDirectory('Deeper', 'd63')); + expect(jmapErrorType(tooDeep)).toBe('invalidProperties'); + expect(jmapErrorProperties(tooDeep)).toEqual(['parentId']); + expect(fileErrorKind(tooDeep)).toBe('tooDeep'); + + const renamed = await caught(client.updateFileNode('n1', { name: 'x' })); + expect(renamed).toBeInstanceOf(Error); + expect(fileErrorKind(renamed)).toBe('forbidden'); + + expect(fileErrorKind(await caught(client.destroyFileNodes(['n2'])))).toBe('overQuota'); + }); + + it('keeps the type of a name 0.16 refuses', async () => { + const { client } = makeClient({}, { + refuseCreate: () => ({ type: 'invalidProperties', description: 'Name is reserved and cannot be used.', properties: ['name'] }), + }); + expect(fileErrorKind(await caught(client.createFileNode('x', 'b1', 'text/plain', 1, null)))).toBe('invalidName'); + }); + + it('sends the bare media type Stalwart 1.0 insists on, when creating and copying', async () => { + const { client, calls } = makeClient({ + me: [{ id: 'f1', name: 'data.csv', parentId: null, blobId: 'b1', type: 'TEXT/CSV', size: 3 }], + }, { capability: STALWART_10_CAPABILITY }); + + await client.createFileNode('a.txt', 'b0', 'Text/Plain; charset=utf-8', 3, null); + // A type no browser sends, as old builds stored for folder markers. + await client.createFileNode('marker', 'b9', 'd', 0, null); + await client.copyFileNode('f1', 'data (copy).csv', null); + + expect(sets(calls).map(a => (a.create as { n0: { type: string } }).n0.type)) + .toEqual(['text/plain', 'application/octet-stream', 'text/csv']); + }); + + it('names a node in a shared drive by the user\'s own rules when the drive lists none', async () => { + const { client, calls } = makeClient({}, { capability: STALWART_10_CAPABILITY, sharedCapability: null }); + await client.createFileNode('con.txt', 'b1', 'text/plain', 3, 'grp:d9'); + expect(sets(calls)[0]).toMatchObject({ accountId: 'grp', create: { n0: { name: 'con_.txt', parentId: 'd9' } } }); + }); + + // Three levels: top > mid > target. + const tree = [ + { id: 'top', name: 'top', parentId: null, blobId: null }, + { id: 'mid', name: 'mid', parentId: 'top', blobId: null }, + { id: 'target', name: 'target', parentId: 'mid', blobId: null }, + { id: 'src', name: 'src', parentId: null, blobId: null }, + { id: 'inner', name: 'inner', parentId: 'src', blobId: null }, + { id: 'f', name: 'f.txt', parentId: 'inner', blobId: 'b1', type: 'text/plain', size: 1 }, + ]; + + it('stops a copy that would go past maxFileNodeDepth before creating anything (Stalwart 1.0)', async () => { + // target sits at level 3; src/inner/f.txt adds three more. + const { client, calls } = makeClient({ me: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: 5 } }); + const refused = await caught(client.copyFileNode('src', 'src', 'target')); + expect(fileErrorKind(refused)).toBe('tooDeep'); + expect(sets(calls)).toEqual([]); + }); + + it('copies when it fits, and checks the other account\'s tree for a shared drive', async () => { + const fits = makeClient({ me: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: 6 } }); + await fits.client.copyFileNode('src', 'src', 'target'); + expect(sets(fits.calls)).toHaveLength(3); + + const shared = makeClient({ me: tree, grp: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: 5 } }); + expect(fileErrorKind(await caught(shared.client.copyFileNode('src', 'src', 'grp:target')))).toBe('tooDeep'); + expect(sets(shared.calls)).toEqual([]); + }); + + it('has no depth to check on 0.16, which advertises maxFileNodeDepth null', async () => { + const { client, calls } = makeClient({ me: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: null } }); + await client.copyFileNode('src', 'src', 'target'); + expect(sets(calls)).toHaveLength(3); + }); +}); diff --git a/lib/__tests__/media-type.test.ts b/lib/__tests__/media-type.test.ts new file mode 100644 index 000000000..413ed6a41 --- /dev/null +++ b/lib/__tests__/media-type.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { mediaTypeEssence } from '../media-type'; + +// Mirrors Stalwart 1.0's `essence_normalisation` test (types/src/media_type/mod.rs): +// 1.0 refuses a FileNode `type` that isn't a bare, valid media type. +describe('mediaTypeEssence', () => { + it('drops parameters and lowercases', () => { + expect(mediaTypeEssence('Text/Plain; charset=utf-8')).toBe('text/plain'); + expect(mediaTypeEssence('image/png')).toBe('image/png'); + expect(mediaTypeEssence(' text/html ')).toBe('text/html'); + expect(mediaTypeEssence('application/vnd.api+json')).toBe('application/vnd.api+json'); + expect(mediaTypeEssence('application/vnd.openxmlformats-officedocument.wordprocessingml.document')) + .toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + }); + + it('falls back to application/octet-stream for anything else', () => { + for (const invalid of ['', 'text', 'text/', '/plain', 'te xt/plain', '-a/b', 'a/b/c', `a/${'b'.repeat(128)}`, 'd']) { + expect(mediaTypeEssence(invalid), JSON.stringify(invalid)).toBe('application/octet-stream'); + } + expect(mediaTypeEssence(null)).toBe('application/octet-stream'); + expect(mediaTypeEssence(undefined)).toBe('application/octet-stream'); + }); + + it('keeps names of the longest length the server takes', () => { + const longest = `a/${'b'.repeat(127)}`; + expect(mediaTypeEssence(longest)).toBe(longest); + }); +}); diff --git a/lib/file-errors.ts b/lib/file-errors.ts new file mode 100644 index 000000000..5050ec723 --- /dev/null +++ b/lib/file-errors.ts @@ -0,0 +1,40 @@ +import { jmapError, jmapErrorProperties, jmapErrorType } from '@/lib/jmap/jmap-error'; + +/** + * Why a FileNode write failed, from the SetError the JMAP client threw + * (see jmapError), for the Files toasts to explain under their own title. + */ +export type FileErrorKind = 'overQuota' | 'tooDeep' | 'invalidName' | 'forbidden' | 'tooLarge'; + +// Stalwart 1.0's description for a node past maxFileNodeDepth. +const TOO_DEEP = 'Maximum folder depth exceeded.'; + +/** The error for a node that would sit deeper than the server allows, worded as Stalwart 1.0 does. */ +export function folderDepthError(): Error { + return jmapError(TOO_DEEP, { type: 'invalidProperties', properties: ['parentId'] }); +} + +export function fileErrorKind(error: unknown): FileErrorKind | null { + switch (jmapErrorType(error)) { + // Stalwart 1.0, once an administrator limits files or folders per account. + case 'overQuota': + return 'overQuota'; + // 1.0 for content over 4 GiB. + case 'tooLarge': + return 'tooLarge'; + case 'forbidden': + return 'forbidden'; + case 'invalidProperties': { + const properties = jmapErrorProperties(error); + if (properties.includes('name')) return 'invalidName'; + // `parentId` is refused for other reasons too (the folder is gone, a + // folder moved into itself); only the description tells depth apart. + if (properties.includes('parentId') && error instanceof Error && /depth/i.test(error.message)) { + return 'tooDeep'; + } + return null; + } + default: + return null; + } +} diff --git a/lib/file-name-rules.ts b/lib/file-name-rules.ts index eeebe0a6f..805c7098d 100644 --- a/lib/file-name-rules.ts +++ b/lib/file-name-rules.ts @@ -1,45 +1,132 @@ /** * FileNode naming rules a server publishes in its filenode capability - * (`forbiddenNameChars`, `forbiddenNodeNames`; Stalwart 0.16.6+). A name that - * breaks them is refused ("Name contains a forbidden character." / "Name is - * reserved and cannot be used."), which used to surface only after an upload - * had started and then aborted the rest of the batch. + * (`forbiddenNameChars`, `forbiddenNodeNames`, `maxSizeFileNodeName`; + * Stalwart 0.16.6+). A name that breaks them is refused ("Name contains a + * forbidden character." / "Name is reserved and cannot be used."), which used + * to surface only after an upload had started. + * + * Stalwart 1.0 checks more than its capability says (`validate_name`, + * jmap/src/file/node.rs), on every name set over JMAP: + * - no control characters (Rust's `char::is_control`: U+0000-001F, U+007F-009F); + * - a reserved name may not be the stem either, the text before the first + * "." (unless that is empty): "con.txt" and "Aux.tar.gz" are refused too. + * 1.0's rules take in 0.16's, so they are applied wherever the server + * publishes rules; on 0.16.6-0.16.23 "con.txt" is renamed although it would + * have been taken. */ export interface FileNameRules { forbiddenChars: string; forbiddenNames: string[]; + /** Longest name, in bytes of UTF-8. */ + maxNameBytes: number; } -export type FileNameProblem = { kind: 'chars'; chars: string } | { kind: 'reserved' }; +export type FileNameProblem = + | { kind: 'chars'; chars: string } + | { kind: 'control' } + | { kind: 'tooLong' } + | { kind: 'reserved' }; + +// Every Stalwart release refuses names over 255 bytes. +const DEFAULT_MAX_NAME_BYTES = 255; export function fileNameRulesFrom(capability: unknown): FileNameRules | null { if (!capability || typeof capability !== 'object') return null; - const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown }; + const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown; maxSizeFileNodeName?: unknown }; const forbiddenChars = typeof cap.forbiddenNameChars === 'string' ? cap.forbiddenNameChars : ''; const forbiddenNames = Array.isArray(cap.forbiddenNodeNames) ? cap.forbiddenNodeNames.filter((n): n is string => typeof n === 'string') : []; if (!forbiddenChars && forbiddenNames.length === 0) return null; - return { forbiddenChars, forbiddenNames }; + const maxNameBytes = typeof cap.maxSizeFileNodeName === 'number' && cap.maxSizeFileNodeName > 0 + ? cap.maxSizeFileNodeName + : DEFAULT_MAX_NAME_BYTES; + return { forbiddenChars, forbiddenNames, maxNameBytes }; +} + +function isControlChar(ch: string): boolean { + const code = ch.codePointAt(0) ?? 0; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); +} + +function utf8Length(text: string): number { + return new TextEncoder().encode(text).length; +} + +// Stalwart compares reserved names ignoring ASCII case only. +function asciiLower(text: string): string { + return text.replace(/[A-Z]/g, c => c.toLowerCase()); +} + +function nameStem(name: string): string { + const dot = name.indexOf('.'); + return dot > 0 ? name.slice(0, dot) : name; +} + +function isReservedName(name: string, reserved: string[]): boolean { + const whole = asciiLower(name); + const stem = asciiLower(nameStem(name)); + return reserved.some(n => asciiLower(n) === whole || asciiLower(n) === stem); } export function fileNameProblem(name: string, rules: FileNameRules | null): FileNameProblem | null { if (!rules) return null; + if (utf8Length(name) > rules.maxNameBytes) return { kind: 'tooLong' }; const bad = [...new Set([...name].filter(c => rules.forbiddenChars.includes(c)))]; if (bad.length) return { kind: 'chars', chars: bad.join(' ') }; - // Matched against the whole name, ignoring case: "CON" is reserved, "CON.txt" is not. - if (rules.forbiddenNames.some(n => n.toLowerCase() === name.toLowerCase())) return { kind: 'reserved' }; + if ([...name].some(isControlChar)) return { kind: 'control' }; + if (isReservedName(name, rules.forbiddenNames)) return { kind: 'reserved' }; return null; } +/** The longest start of `text` that fits in `maxBytes` of UTF-8, whole characters only. */ +function cutToBytes(text: string, maxBytes: number): string { + let out = ''; + let used = 0; + for (const ch of text) { + const bytes = utf8Length(ch); + if (used + bytes > maxBytes) break; + out += ch; + used += bytes; + } + return out; +} + +/** `name` cut to `maxBytes`, keeping the extension when it leaves room for a stem. */ +function fitToBytes(name: string, maxBytes: number): string { + if (utf8Length(name) <= maxBytes) return name; + const dot = name.lastIndexOf('.'); + const ext = dot > 0 ? name.slice(dot) : ''; + const extBytes = utf8Length(ext); + if (!ext || extBytes >= maxBytes) return cutToBytes(name, maxBytes); + return cutToBytes(name.slice(0, dot), maxBytes - extBytes) + ext; +} + /** - * A variant of `name` the server accepts: forbidden characters become "_", - * a reserved name gets "_" appended. Used for uploads, where the name comes - * from the local file system and cannot be asked for again. + * A reserved name made acceptable: "_" after the stem ("con.txt" becomes + * "con_.txt", "CON" becomes "CON_", ".." becomes ".._"). A name already at + * the byte limit has the stem's last character replaced instead. + */ +function unreserved(name: string, maxBytes: number): string { + const stemEnd = nameStem(name).length; + const marked = `${name.slice(0, stemEnd)}_${name.slice(stemEnd)}`; + if (utf8Length(marked) <= maxBytes) return marked; + const stem = [...name.slice(0, stemEnd)]; + stem[stem.length - 1] = '_'; + return stem.join('') + name.slice(stemEnd); +} + +/** + * A variant of `name` the server accepts: forbidden and control characters + * become "_", a reserved name or stem gets "_" after the stem, and a name over + * the byte limit is shortened, keeping its extension. Used for uploads, + * where the name comes from the local file system and cannot be asked for + * again. */ export function acceptedFileName(name: string, rules: FileNameRules | null): string { if (!rules) return name; - let out = [...name].map(c => (rules.forbiddenChars.includes(c) ? '_' : c)).join(''); - if (rules.forbiddenNames.some(n => n.toLowerCase() === out.toLowerCase())) out = `${out}_`; + let out = [...name].map(c => (isControlChar(c) || rules.forbiddenChars.includes(c) ? '_' : c)).join(''); + out = fitToBytes(out, rules.maxNameBytes); + if (isReservedName(out, rules.forbiddenNames)) out = unreserved(out, rules.maxNameBytes); return out; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index d3730b047..e65860038 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -73,6 +73,8 @@ export interface IJMAPClient { getAccountInfo(accountId: string): AccountInfo | null; /** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */ getFileNameRules?(accountId?: string): FileNameRules | null; + /** How deep a FileNode may sit (Stalwart 1.0: 64), or null for no limit. */ + getMaxFileNodeDepth?(accountId?: string): number | null; getMaxSizeUpload(): number; /** RFC 8621 maxSizeAttachmentsPerEmail of the mail account (0 = unknown). */ getMaxSizeAttachmentsPerEmail?(): number; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1b8c6c2b9..b9363f7af 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -15,12 +15,15 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; import { fileNodeKind } from "./filenode-kind"; +import { fileNodeDepth } from "./filenode-depth"; import { contactFromWire, contactToWire } from "./contact-wire"; import { SchedulingDeniedError } from "./scheduling-error"; import { jmapError, jmapErrorType, jmapSetError, type JmapErrorInfo } from "./jmap-error"; export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; +import { folderDepthError } from "@/lib/file-errors"; +import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type"; import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; import type { AccountInfo } from "./types"; @@ -8140,6 +8143,17 @@ export class JMAPClient implements IJMAPClient { return this.getAccountCapability("urn:ietf:params:jmap:filenode", accountId) as Record | undefined; } + /** + * The filenode capability whose limits apply in `accountId`. Stalwart + * publishes the same ones for every account, and a shared account may + * come without any (see getFilesCapableAccountIds), so the user's own + * files account stands in. + */ + private fileNodeLimits(accountId?: string): Record | undefined { + const own = this.getFilesAccountId(); + return this.fileNodeCapability(accountId || own) ?? this.fileNodeCapability(own); + } + /** * Stalwart before 0.16.6 implements an older File Storage draft (old * rights names, 30-character MIME types, no naming rules). The draft update @@ -8152,7 +8166,24 @@ export class JMAPClient implements IJMAPClient { /** Naming rules for new FileNodes in `accountId`, if the server publishes any. */ getFileNameRules(accountId?: string): FileNameRules | null { - return fileNameRulesFrom(this.fileNodeCapability(accountId || this.getFilesAccountId())); + return fileNameRulesFrom(this.fileNodeLimits(accountId)); + } + + /** + * How deep a node may sit (`maxFileNodeDepth`: 64 on Stalwart 1.0), or + * null for no limit (0.16 advertises null). + */ + getMaxFileNodeDepth(accountId?: string): number | null { + const depth = this.fileNodeLimits(accountId)?.maxFileNodeDepth; + return typeof depth === 'number' && depth > 0 ? depth : null; + } + + /** The `type` to create a file node in `accountId` with. */ + private fileNodeType(accountId: string, type: string | null | undefined): string { + // Stalwart 1.0 only takes a bare, valid media type; servers before + // 0.16.6 refuse more than 30 characters (most OOXML types). + const essence = mediaTypeEssence(type); + return this.isLegacyFileNodeServer(accountId) && essence.length > 30 ? OCTET_STREAM : essence; } /** Namespace a node created in `accountId` the way the listings do. */ @@ -8198,7 +8229,7 @@ export class JMAPClient implements IJMAPClient { ); const result = response.methodResponses?.[0]; if (!result || result[0] === "error") { - throw new Error(result?.[1]?.description || "FileNode/set create failed"); + throw jmapError(result?.[1]?.description || "FileNode/set create failed", result?.[1]); } const created = result[1].created?.n0; if (created) { @@ -8209,7 +8240,7 @@ export class JMAPClient implements IJMAPClient { const err = result[1].notCreated?.n0; // Servers before 0.16.6 ignore onExists and refuse the clash instead. if (attempt < 20 && /already exists/i.test(err?.description ?? '')) continue; - throw new Error(err?.description || `Failed to create ${what}`); + throw jmapError(err?.description || `Failed to create ${what}`, err); } } @@ -8237,11 +8268,7 @@ export class JMAPClient implements IJMAPClient { async createFileNode(name: string, blobId: string, type: string, _size: number, parentId: string | null): Promise { const parent = this.resolveFileNodeId(parentId); - // Servers before 0.16.6 refuse MIME types over 30 characters (most OOXML - // types); later ones take up to 255. - const maxTypeLength = this.isLegacyFileNodeServer(parent.accountId) ? 30 : 255; - const safeType = type.length > maxTypeLength ? 'application/octet-stream' : type; - const fileProps: Record = { name, type: safeType, blobId }; + const fileProps: Record = { name, type: this.fileNodeType(parent.accountId, type), blobId }; if (parent.id !== null) { fileProps.parentId = parent.id; } @@ -8267,10 +8294,11 @@ export class JMAPClient implements IJMAPClient { const result = response.methodResponses?.[0]; if (!result || result[0] === "error") { - throw new Error(result?.[1]?.description || "FileNode/set update failed"); + throw jmapError(result?.[1]?.description || "FileNode/set update failed", result?.[1]); } - if (result[1].notUpdated?.[node.id as string]) { - throw new Error(result[1].notUpdated[node.id as string].description || "Failed to update file node"); + const refused = result[1].notUpdated?.[node.id as string]; + if (refused) { + throw jmapError(refused.description || "Failed to update file node", refused); } } @@ -8359,7 +8387,7 @@ export class JMAPClient implements IJMAPClient { const result = response.methodResponses?.[0]; if (!result || result[0] === "error") { - throw new Error(result?.[1]?.description || "FileNode/set destroy failed"); + throw jmapError(result?.[1]?.description || "FileNode/set destroy failed", result?.[1]); } const notDestroyedMap: Record = result[1].notDestroyed || {}; @@ -8367,7 +8395,7 @@ export class JMAPClient implements IJMAPClient { if (notDestroyedIds.length > 0) { const firstError = notDestroyedMap[notDestroyedIds[0]]; - throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`); + throw jmapError(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`, firstError); } destroyed.push(...((result[1].destroyed || []) as string[]).map(rawId => byRawId.get(rawId) ?? rawId)); @@ -8401,7 +8429,8 @@ export class JMAPClient implements IJMAPClient { if (fileNodeKind(original) === 'symlink') throw new Error('Symbolic links cannot be copied'); const isFolderNode = (n: FileNode) => fileNodeKind(n) === 'directory'; - // The whole subtree is laid out from one listing before the first create. + // The whole subtree is laid out from one listing and checked before the + // first create. const childrenOf = new Map(); for (const node of tree) { if (node.parentId == null) continue; @@ -8423,11 +8452,25 @@ export class JMAPClient implements IJMAPClient { }; const plan = collect(original); + // Stalwart 1.0 refuses nodes below maxFileNodeDepth; a copy that would + // reach past it fails here rather than part-way. + const maxDepth = this.getMaxFileNodeDepth(target.accountId); + if (maxDepth !== null) { + const height = (entry: CopyEntry): number => + 1 + entry.children.reduce((tallest, child) => Math.max(tallest, height(child)), 0); + const targetTree = target.accountId === source.accountId + ? tree + : await this.fetchAllFileNodes(target.accountId).catch(() => null); + if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) { + throw folderDepthError(); + } + } + const createCopy = (node: FileNode, name: string, parent: string | null): Promise => { const props: Record = { name }; if (parent !== null) props.parentId = parent; if (!isFolderNode(node)) { - props.type = node.type; + props.type = this.fileNodeType(target.accountId, node.type); props.blobId = node.blobId; } return this.createFileNodeIn(target.accountId, props, 'copy'); diff --git a/lib/jmap/filenode-depth.ts b/lib/jmap/filenode-depth.ts new file mode 100644 index 000000000..659558503 --- /dev/null +++ b/lib/jmap/filenode-depth.ts @@ -0,0 +1,18 @@ +import type { FileNode } from './types'; + +/** + * How deep a node sits in `nodes`: 0 for the root (`null`), 1 for a node at + * the top level. Stalwart 1.0 refuses a node below its maxFileNodeDepth, + * counted this way. A parent missing from the listing ends the walk, so the + * result can only come out low. + */ +export function fileNodeDepth(nodes: Pick[], id: string | null): number { + const parents = new Map(nodes.map(n => [n.id, n.parentId ?? null])); + const seen = new Set(); + let depth = 0; + for (let current = id; current !== null && !seen.has(current); current = parents.get(current) ?? null) { + seen.add(current); + depth++; + } + return depth; +} diff --git a/lib/media-type.ts b/lib/media-type.ts new file mode 100644 index 000000000..27c75801e --- /dev/null +++ b/lib/media-type.ts @@ -0,0 +1,22 @@ +/** What Stalwart stores for content without a usable media type. */ +export const OCTET_STREAM = 'application/octet-stream'; + +// RFC 6838 restricted-name: a letter or digit, then at most 126 more from a +// small set. +const RESTRICTED_NAME = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/; + +/** + * The essence of a media type: `top/sub` in lowercase, without parameters + * ("Text/Plain; charset=utf-8" becomes "text/plain"), or + * application/octet-stream when there is none. Stalwart 1.0 refuses any + * other FileNode `type` (`is_valid_media_type`, types/src/media_type/mod.rs); + * 0.16.6+ take anything with a "/" up to 256 characters. + */ +export function mediaTypeEssence(value: string | null | undefined): string { + const essence = (value ?? '').split(';', 1)[0].replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, ''); + const slash = essence.indexOf('/'); + if (slash < 0) return OCTET_STREAM; + const top = essence.slice(0, slash); + const sub = essence.slice(slash + 1); + return RESTRICTED_NAME.test(top) && RESTRICTED_NAME.test(sub) ? essence.toLowerCase() : OCTET_STREAM; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 0cee325e3..66f299a8e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3354,6 +3354,8 @@ "new_name": "الاسم الجديد", "name_forbidden_chars": "لا يسمح الخادم بهذه الأحرف في الأسماء: {chars}", "name_reserved": "هذا الاسم محجوز على الخادم.", + "name_control_chars": "لا يمكن أن تحتوي الأسماء على أحرف تحكم.", + "name_too_long": "هذا الاسم طويل جدًا بالنسبة للخادم.", "delete": "حذف", "delete_confirm_title": "حذف المورد", "delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.", @@ -3373,6 +3375,12 @@ "uploading": "جارٍ الرفع...", "upload_success": "{count, plural, one {تم رفع ملف واحد} other {تم رفع # ملف}}", "upload_error": "فشل رفع الملف", + "upload_failed": "{count, plural, one {تعذّر رفع ملف واحد} two {تعذّر رفع ملفين} few {تعذّر رفع # ملفات} other {تعذّر رفع # ملف}}", + "error_over_quota": "لقد بلغت الحد الأقصى لعدد الملفات أو المجلدات. احذف بعضها ثم حاول مرة أخرى.", + "error_too_deep": "لا يمكن تداخل المجلدات بهذا العمق.", + "error_invalid_name": "لا يقبل الخادم هذا الاسم.", + "error_forbidden": "ليست لديك صلاحية للقيام بذلك هنا.", + "error_too_large": "هذا الملف كبير جدًا بالنسبة للخادم.", "create_folder_success": "تم إنشاء المجلد", "create_folder_error": "فشل إنشاء المجلد", "delete_success": "تم الحذف بنجاح", diff --git a/locales/ca/common.json b/locales/ca/common.json index dc35c8bbc..cc1001d2a 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3354,6 +3354,8 @@ "new_name": "Nom nou", "name_forbidden_chars": "El servidor no permet aquests caràcters als noms: {chars}", "name_reserved": "Aquest nom està reservat al servidor.", + "name_control_chars": "Els noms no poden contenir caràcters de control.", + "name_too_long": "Aquest nom és massa llarg per al servidor.", "delete": "Suprimeix", "delete_confirm_title": "Suprimeix el recurs", "delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.", @@ -3373,6 +3375,12 @@ "uploading": "Pujant...", "upload_success": "{count, plural, one {1 fitxer pujat} other {# fitxers pujats}}", "upload_error": "No s'ha pogut pujar el fitxer", + "upload_failed": "{count, plural, one {No s'ha pogut pujar 1 fitxer} other {No s'han pogut pujar # fitxers}}", + "error_over_quota": "Heu arribat al límit de fitxers o carpetes. Suprimiu-ne alguns i torneu-ho a provar.", + "error_too_deep": "Les carpetes no es poden imbricar tan profundament.", + "error_invalid_name": "El servidor no accepta aquest nom.", + "error_forbidden": "No teniu permís per fer-ho aquí.", + "error_too_large": "Aquest fitxer és massa gran per al servidor.", "create_folder_success": "Carpeta creada", "create_folder_error": "No s'ha pogut crear la carpeta", "delete_success": "Suprimit correctament", diff --git a/locales/cs/common.json b/locales/cs/common.json index 60ef6e23b..53ff0b59b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3309,6 +3309,8 @@ "new_name": "Nový název", "name_forbidden_chars": "Server nepovoluje v názvech tyto znaky: {chars}", "name_reserved": "Tento název je na serveru vyhrazen.", + "name_control_chars": "Názvy nesmí obsahovat řídicí znaky.", + "name_too_long": "Tento název je pro server příliš dlouhý.", "delete": "Odstranit", "delete_confirm_title": "Odstranit položku", "delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.", @@ -3326,6 +3328,12 @@ "uploading": "Nahrávání...", "upload_success": "{count, plural, one {Nahrán 1 soubor} few {Nahrány # soubory} other {Nahráno # souborů}}", "upload_error": "Nahrání souboru selhalo", + "upload_failed": "{count, plural, one {1 soubor se nepodařilo nahrát} few {# soubory se nepodařilo nahrát} other {# souborů se nepodařilo nahrát}}", + "error_over_quota": "Dosáhli jste limitu počtu souborů nebo složek. Některé smažte a zkuste to znovu.", + "error_too_deep": "Složky nelze vnořit tak hluboko.", + "error_invalid_name": "Server tento název nepřijímá.", + "error_forbidden": "K této akci zde nemáte oprávnění.", + "error_too_large": "Tento soubor je pro server příliš velký.", "create_folder_success": "Složka byla vytvořena", "create_folder_error": "Vytvoření složky selhalo", "delete_success": "Úspěšně odstraněno", diff --git a/locales/da/common.json b/locales/da/common.json index 96749b2dd..9249d99ec 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3354,6 +3354,8 @@ "new_name": "Nyt navn", "name_forbidden_chars": "Serveren tillader ikke disse tegn i navne: {chars}", "name_reserved": "Dette navn er reserveret på serveren.", + "name_control_chars": "Navne må ikke indeholde kontroltegn.", + "name_too_long": "Dette navn er for langt til serveren.", "delete": "Slet", "delete_confirm_title": "Slet ressource", "delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.", @@ -3371,6 +3373,12 @@ "uploading": "Uploader...", "upload_success": "{count, plural, one {1 fil uploadet} other {# filer uploadet}}", "upload_error": "Kunne ikke uploade fil", + "upload_failed": "{count, plural, one {1 fil kunne ikke uploades} other {# filer kunne ikke uploades}}", + "error_over_quota": "Du har nået grænsen for filer eller mapper. Slet nogle, og prøv igen.", + "error_too_deep": "Mapper kan ikke indlejres så dybt.", + "error_invalid_name": "Serveren accepterer ikke dette navn.", + "error_forbidden": "Du har ikke tilladelse til at gøre dette her.", + "error_too_large": "Denne fil er for stor til serveren.", "create_folder_success": "Mappe oprettet", "create_folder_error": "Kunne ikke oprette mappe", "delete_success": "Slettet", diff --git a/locales/de/common.json b/locales/de/common.json index 132299c68..c8a28ed35 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3309,6 +3309,8 @@ "new_name": "Neuer Name", "name_forbidden_chars": "Diese Zeichen sind in Namen auf dem Server nicht erlaubt: {chars}", "name_reserved": "Dieser Name ist auf dem Server reserviert.", + "name_control_chars": "Namen dürfen keine Steuerzeichen enthalten.", + "name_too_long": "Dieser Name ist für den Server zu lang.", "delete": "Löschen", "delete_confirm_title": "Ressource löschen", "delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.", @@ -3326,6 +3328,12 @@ "uploading": "Wird hochgeladen...", "upload_success": "{count, plural, one {1 Datei hochgeladen} other {# Dateien hochgeladen}}", "upload_error": "Datei konnte nicht hochgeladen werden", + "upload_failed": "{count, plural, one {1 Datei konnte nicht hochgeladen werden} other {# Dateien konnten nicht hochgeladen werden}}", + "error_over_quota": "Die Höchstzahl an Dateien oder Ordnern ist erreicht. Löschen Sie einige und versuchen Sie es erneut.", + "error_too_deep": "Ordner lassen sich nicht so tief verschachteln.", + "error_invalid_name": "Der Server akzeptiert diesen Namen nicht.", + "error_forbidden": "Dazu fehlt Ihnen hier die Berechtigung.", + "error_too_large": "Diese Datei ist für den Server zu groß.", "create_folder_success": "Ordner erstellt", "create_folder_error": "Ordner konnte nicht erstellt werden", "delete_success": "Erfolgreich gelöscht", diff --git a/locales/en/common.json b/locales/en/common.json index 7049b0873..82debeecf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3354,6 +3354,8 @@ "new_name": "New name", "name_forbidden_chars": "The server does not allow these characters in names: {chars}", "name_reserved": "This name is reserved on the server.", + "name_control_chars": "Names cannot contain control characters.", + "name_too_long": "This name is too long for the server.", "delete": "Delete", "delete_confirm_title": "Delete resource", "delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.", @@ -3373,6 +3375,12 @@ "uploading": "Uploading...", "upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}", "upload_error": "Failed to upload file", + "upload_failed": "{count, plural, one {1 file could not be uploaded} other {# files could not be uploaded}}", + "error_over_quota": "You have reached the limit on files or folders. Delete some and try again.", + "error_too_deep": "Folders cannot be nested this deeply.", + "error_invalid_name": "The server does not accept this name.", + "error_forbidden": "You do not have permission to do this here.", + "error_too_large": "This file is too large for the server.", "create_folder_success": "Folder created", "create_folder_error": "Failed to create folder", "delete_success": "Deleted successfully", diff --git a/locales/es/common.json b/locales/es/common.json index 0ea5c4579..5e26437ee 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3309,6 +3309,8 @@ "new_name": "Nuevo nombre", "name_forbidden_chars": "El servidor no permite estos caracteres en los nombres: {chars}", "name_reserved": "Este nombre está reservado en el servidor.", + "name_control_chars": "Los nombres no pueden contener caracteres de control.", + "name_too_long": "Este nombre es demasiado largo para el servidor.", "delete": "Eliminar", "delete_confirm_title": "Eliminar recurso", "delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.", @@ -3326,6 +3328,12 @@ "uploading": "Subiendo...", "upload_success": "{count, plural, one {1 archivo subido} other {# archivos subidos}}", "upload_error": "Error al subir el archivo", + "upload_failed": "{count, plural, one {No se pudo subir 1 archivo} other {No se pudieron subir # archivos}}", + "error_over_quota": "Se alcanzó el límite de archivos o carpetas. Elimine algunos e inténtelo de nuevo.", + "error_too_deep": "Las carpetas no se pueden anidar a tanta profundidad.", + "error_invalid_name": "El servidor no acepta este nombre.", + "error_forbidden": "No tiene permiso para hacer esto aquí.", + "error_too_large": "Este archivo es demasiado grande para el servidor.", "create_folder_success": "Carpeta creada", "create_folder_error": "Error al crear la carpeta", "delete_success": "Eliminado correctamente", diff --git a/locales/fa/common.json b/locales/fa/common.json index e72fffd64..23c697541 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3354,6 +3354,8 @@ "new_name": "نام جدید", "name_forbidden_chars": "سرور این نویسه‌ها را در نام‌ها مجاز نمی‌داند: {chars}", "name_reserved": "این نام در سرور رزرو شده است.", + "name_control_chars": "نام‌ها نمی‌توانند نویسه‌های کنترلی داشته باشند.", + "name_too_long": "این نام برای سرور بیش از حد طولانی است.", "delete": "حذف", "delete_confirm_title": "حذف منبع", "delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.", @@ -3373,6 +3375,12 @@ "uploading": "در حال بارگذاری...", "upload_success": "{count, plural, one {۱ فایل بارگذاری شد} other {# فایل بارگذاری شد}}", "upload_error": "بارگذاری فایل ناموفق بود", + "upload_failed": "{count, plural, one {۱ فایل بارگذاری نشد} other {# فایل بارگذاری نشد}}", + "error_over_quota": "به حداکثر تعداد فایل‌ها یا پوشه‌ها رسیده‌اید. برخی را حذف کنید و دوباره تلاش کنید.", + "error_too_deep": "پوشه‌ها را نمی‌توان تا این عمق تودرتو کرد.", + "error_invalid_name": "سرور این نام را نمی‌پذیرد.", + "error_forbidden": "اجازهٔ انجام این کار را در اینجا ندارید.", + "error_too_large": "این فایل برای سرور بیش از حد بزرگ است.", "create_folder_success": "پوشه ایجاد شد", "create_folder_error": "ایجاد پوشه ناموفق بود", "delete_success": "با موفقیت حذف شد", diff --git a/locales/fr/common.json b/locales/fr/common.json index db013f740..1877b7f86 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3309,6 +3309,8 @@ "new_name": "Nouveau nom", "name_forbidden_chars": "Le serveur n'autorise pas ces caractères dans les noms : {chars}", "name_reserved": "Ce nom est réservé sur le serveur.", + "name_control_chars": "Les noms ne peuvent pas contenir de caractères de contrôle.", + "name_too_long": "Ce nom est trop long pour le serveur.", "delete": "Supprimer", "delete_confirm_title": "Supprimer la ressource", "delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.", @@ -3326,6 +3328,12 @@ "uploading": "Téléversement en cours...", "upload_success": "{count, plural, one {1 fichier téléversé} other {# fichiers téléversés}}", "upload_error": "Échec du téléversement du fichier", + "upload_failed": "{count, plural, one {1 fichier n'a pas pu être téléversé} other {# fichiers n'ont pas pu être téléversés}}", + "error_over_quota": "Vous avez atteint la limite de fichiers ou de dossiers. Supprimez-en quelques-uns et réessayez.", + "error_too_deep": "Les dossiers ne peuvent pas être imbriqués aussi profondément.", + "error_invalid_name": "Le serveur n'accepte pas ce nom.", + "error_forbidden": "Vous n'avez pas l'autorisation de faire cela ici.", + "error_too_large": "Ce fichier est trop volumineux pour le serveur.", "create_folder_success": "Dossier créé", "create_folder_error": "Échec de la création du dossier", "delete_success": "Supprimé avec succès", diff --git a/locales/he/common.json b/locales/he/common.json index d3f8a6e8b..c3a39c9a4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3232,6 +3232,8 @@ "new_name": "שם חדש", "name_forbidden_chars": "השרת אינו מתיר את התווים האלה בשמות: {chars}", "name_reserved": "השם הזה שמור בשרת.", + "name_control_chars": "שמות אינם יכולים להכיל תווי בקרה.", + "name_too_long": "השם הזה ארוך מדי עבור השרת.", "delete": "לִמְחוֹק", "delete_confirm_title": "מחק משאב", "delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.", @@ -3249,6 +3251,12 @@ "uploading": "מעלה...", "upload_success": "{count, plural, one {קובץ אחד הועלה} other {# קבצים הועלו}}", "upload_error": "העלאת הקובץ נכשלה", + "upload_failed": "{count, plural, one {לא ניתן היה להעלות קובץ אחד} other {לא ניתן היה להעלות # קבצים}}", + "error_over_quota": "הגעת למגבלת הקבצים או התיקיות. יש למחוק חלק מהם ולנסות שוב.", + "error_too_deep": "לא ניתן לקנן תיקיות לעומק כזה.", + "error_invalid_name": "השרת אינו מקבל את השם הזה.", + "error_forbidden": "אין לך הרשאה לבצע פעולה זו כאן.", + "error_too_large": "הקובץ הזה גדול מדי עבור השרת.", "create_folder_success": "התיקיה נוצרה", "create_folder_error": "יצירת התיקיה נכשלה", "delete_success": "נמחק בהצלחה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 013002ac0..ff51c0063 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3354,6 +3354,8 @@ "new_name": "Új név", "name_forbidden_chars": "A kiszolgáló nem engedi ezeket a karaktereket a nevekben: {chars}", "name_reserved": "Ez a név a kiszolgálón foglalt.", + "name_control_chars": "A nevek nem tartalmazhatnak vezérlőkaraktereket.", + "name_too_long": "Ez a név túl hosszú a kiszolgáló számára.", "delete": "Törlés", "delete_confirm_title": "Erőforrás törlése", "delete_confirm_message": "Biztosan törölni szeretnéd a \"{name}\" elemet? Ez nem vonható vissza.", @@ -3373,6 +3375,12 @@ "uploading": "Feltöltés...", "upload_success": "{count, plural, one {1 fájl feltöltve} other {# fájl feltöltve}}", "upload_error": "Nem sikerült feltölteni a fájlt", + "upload_failed": "{count, plural, one {1 fájlt nem sikerült feltölteni} other {# fájlt nem sikerült feltölteni}}", + "error_over_quota": "Elérted a fájlok vagy mappák számának korlátját. Törölj néhányat, és próbáld újra.", + "error_too_deep": "A mappák nem ágyazhatók egymásba ilyen mélyen.", + "error_invalid_name": "A kiszolgáló nem fogadja el ezt a nevet.", + "error_forbidden": "Ehhez itt nincs jogosultságod.", + "error_too_large": "Ez a fájl túl nagy a kiszolgáló számára.", "create_folder_success": "Mappa létrehozva", "create_folder_error": "Nem sikerült létrehozni a mappát", "delete_success": "Sikeresen törölve", diff --git a/locales/it/common.json b/locales/it/common.json index 8a1c70eec..0e2fe1581 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3309,6 +3309,8 @@ "new_name": "Nuovo nome", "name_forbidden_chars": "Il server non consente questi caratteri nei nomi: {chars}", "name_reserved": "Questo nome è riservato sul server.", + "name_control_chars": "I nomi non possono contenere caratteri di controllo.", + "name_too_long": "Questo nome è troppo lungo per il server.", "delete": "Elimina", "delete_confirm_title": "Elimina risorsa", "delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.", @@ -3326,6 +3328,12 @@ "uploading": "Caricamento in corso...", "upload_success": "{count, plural, one {1 file caricato} other {# file caricati}}", "upload_error": "Caricamento del file non riuscito", + "upload_failed": "{count, plural, one {Impossibile caricare 1 file} other {Impossibile caricare # file}}", + "error_over_quota": "Hai raggiunto il limite di file o cartelle. Eliminane alcuni e riprova.", + "error_too_deep": "Le cartelle non possono essere annidate così in profondità.", + "error_invalid_name": "Il server non accetta questo nome.", + "error_forbidden": "Non hai l'autorizzazione per farlo qui.", + "error_too_large": "Questo file è troppo grande per il server.", "create_folder_success": "Cartella creata", "create_folder_error": "Creazione della cartella non riuscita", "delete_success": "Eliminato con successo", diff --git a/locales/ja/common.json b/locales/ja/common.json index f76ceec15..02e9a78f1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3309,6 +3309,8 @@ "new_name": "新しい名前", "name_forbidden_chars": "サーバーでは名前に次の文字を使用できません: {chars}", "name_reserved": "この名前はサーバーで予約されています。", + "name_control_chars": "名前に制御文字は使用できません。", + "name_too_long": "この名前はサーバーには長すぎます。", "delete": "削除", "delete_confirm_title": "リソースを削除", "delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。", @@ -3326,6 +3328,12 @@ "uploading": "アップロード中...", "upload_success": "{count, plural, other {#件のファイルをアップロードしました}}", "upload_error": "ファイルのアップロードに失敗しました", + "upload_failed": "{count, plural, other {#件のファイルをアップロードできませんでした}}", + "error_over_quota": "ファイルまたはフォルダーの上限に達しました。いくつか削除してから、もう一度お試しください。", + "error_too_deep": "フォルダーをこれ以上深く入れ子にすることはできません。", + "error_invalid_name": "サーバーはこの名前を受け付けません。", + "error_forbidden": "ここでこの操作を行う権限がありません。", + "error_too_large": "このファイルはサーバーには大きすぎます。", "create_folder_success": "フォルダーを作成しました", "create_folder_error": "フォルダーの作成に失敗しました", "delete_success": "正常に削除しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 85fb986fd..6e5c3a6a1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3309,6 +3309,8 @@ "new_name": "새 이름", "name_forbidden_chars": "서버에서 이름에 다음 문자를 허용하지 않습니다: {chars}", "name_reserved": "이 이름은 서버에서 예약되어 있습니다.", + "name_control_chars": "이름에 제어 문자를 사용할 수 없습니다.", + "name_too_long": "이 이름은 서버에서 사용하기에 너무 깁니다.", "delete": "삭제", "delete_confirm_title": "항목 삭제", "delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.", @@ -3326,6 +3328,12 @@ "uploading": "업로드 중...", "upload_success": "{count}개의 파일이 업로드되었어요", "upload_error": "파일 업로드에 실패했어요", + "upload_failed": "{count}개의 파일을 업로드하지 못했습니다", + "error_over_quota": "파일 또는 폴더 수 한도에 도달했습니다. 일부를 삭제한 후 다시 시도하세요.", + "error_too_deep": "폴더를 이렇게 깊이 중첩할 수 없습니다.", + "error_invalid_name": "서버에서 이 이름을 허용하지 않습니다.", + "error_forbidden": "여기에서 이 작업을 수행할 권한이 없습니다.", + "error_too_large": "이 파일은 서버에서 처리하기에 너무 큽니다.", "create_folder_success": "폴더가 만들어졌어요", "create_folder_error": "폴더를 만들지 못했어요", "delete_success": "성공적으로 삭제되었어요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 107761f8b..e83e5a77b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3309,6 +3309,8 @@ "new_name": "Jauns nosaukums", "name_forbidden_chars": "Serveris neatļauj šīs rakstzīmes nosaukumos: {chars}", "name_reserved": "Šis nosaukums serverī ir rezervēts.", + "name_control_chars": "Nosaukumos nedrīkst būt vadības rakstzīmes.", + "name_too_long": "Šis nosaukums serverim ir pārāk garš.", "delete": "Dzēst", "delete_confirm_title": "Dzēst resursu", "delete_confirm_message": "Vai tiešām vēlaties dzēst «{name}»? Šo darbību nevar atcelt.", @@ -3326,6 +3328,12 @@ "uploading": "Augšupielādē...", "upload_success": "Augšupielādēts {count, plural, one {1 fails} other {# faili}}", "upload_error": "Neizdevās augšupielādēt failu", + "upload_failed": "{count, plural, one {Neizdevās augšupielādēt # failu} other {Neizdevās augšupielādēt # failus}}", + "error_over_quota": "Ir sasniegts failu vai mapju skaita ierobežojums. Izdzēsiet dažus un mēģiniet vēlreiz.", + "error_too_deep": "Mapes nevar ievietot tik dziļi citu citā.", + "error_invalid_name": "Serveris nepieņem šo nosaukumu.", + "error_forbidden": "Jums šeit nav atļaujas to darīt.", + "error_too_large": "Šis fails serverim ir pārāk liels.", "create_folder_success": "Mape izveidota", "create_folder_error": "Neizdevās izveidot mapi", "delete_success": "Sekmīgi izdzēsts", diff --git a/locales/mn/common.json b/locales/mn/common.json index d9e4be8ab..1b98b3d59 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3354,6 +3354,8 @@ "new_name": "Шинэ нэр", "name_forbidden_chars": "Сервер нэрэнд эдгээр тэмдэгтийг зөвшөөрдөггүй: {chars}", "name_reserved": "Энэ нэр сервер дээр нөөцлөгдсөн.", + "name_control_chars": "Нэрэнд удирдах тэмдэгт агуулж болохгүй.", + "name_too_long": "Энэ нэр серверийн хувьд хэт урт байна.", "delete": "Устгах", "delete_confirm_title": "Нөөцийг устгах", "delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.", @@ -3373,6 +3375,12 @@ "uploading": "Байршуулж байна...", "upload_success": "{count, plural, one {1 файл дээш ачигдлаа} other {# файл дээш ачигдлаа}}", "upload_error": "Файл байршуулж чадсангүй", + "upload_failed": "{count, plural, one {1 файлыг дээш ачаалж чадсангүй} other {# файлыг дээш ачаалж чадсангүй}}", + "error_over_quota": "Файл эсвэл хавтасны хязгаарт хүрсэн байна. Заримыг нь устгаад дахин оролдоно уу.", + "error_too_deep": "Хавтсуудыг ийм гүн давхарлах боломжгүй.", + "error_invalid_name": "Сервер энэ нэрийг хүлээн авахгүй байна.", + "error_forbidden": "Танд энд үүнийг хийх эрх байхгүй.", + "error_too_large": "Энэ файл серверийн хувьд хэт том байна.", "create_folder_success": "Хавтас үүсгэсэн", "create_folder_error": "Хавтас үүсгэж чадсангүй", "delete_success": "Амжилттай устгасан", diff --git a/locales/nb/common.json b/locales/nb/common.json index 4c371e7b0..b2247718e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3354,6 +3354,8 @@ "new_name": "Nytt navn", "name_forbidden_chars": "Serveren tillater ikke disse tegnene i navn: {chars}", "name_reserved": "Dette navnet er reservert på serveren.", + "name_control_chars": "Navn kan ikke inneholde kontrolltegn.", + "name_too_long": "Dette navnet er for langt for serveren.", "delete": "Slett", "delete_confirm_title": "Slett fil eller mappe", "delete_confirm_message": "Er du sikker på at du vil slette «{name}»? Dette kan ikke angres.", @@ -3373,6 +3375,12 @@ "uploading": "Laster opp...", "upload_success": "{count, plural, one {1 fil lastet opp} other {# filer lastet opp}}", "upload_error": "Kunne ikke laste opp filen", + "upload_failed": "{count, plural, one {1 fil kunne ikke lastes opp} other {# filer kunne ikke lastes opp}}", + "error_over_quota": "Du har nådd grensen for filer eller mapper. Slett noen og prøv igjen.", + "error_too_deep": "Mapper kan ikke nestes så dypt.", + "error_invalid_name": "Serveren godtar ikke dette navnet.", + "error_forbidden": "Du har ikke tillatelse til å gjøre dette her.", + "error_too_large": "Denne filen er for stor for serveren.", "create_folder_success": "Mappen er opprettet", "create_folder_error": "Kunne ikke opprette mappe", "delete_success": "Elementet er slettet", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0eb816803..4ed80c08a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3309,6 +3309,8 @@ "new_name": "Nieuwe naam", "name_forbidden_chars": "De server staat deze tekens niet toe in namen: {chars}", "name_reserved": "Deze naam is gereserveerd op de server.", + "name_control_chars": "Namen mogen geen besturingstekens bevatten.", + "name_too_long": "Deze naam is te lang voor de server.", "delete": "Verwijderen", "delete_confirm_title": "Bron verwijderen", "delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.", @@ -3326,6 +3328,12 @@ "uploading": "Uploaden...", "upload_success": "{count, plural, one {1 bestand geüpload} other {# bestanden geüpload}}", "upload_error": "Bestand uploaden mislukt", + "upload_failed": "{count, plural, one {1 bestand kon niet worden geüpload} other {# bestanden konden niet worden geüpload}}", + "error_over_quota": "Het maximumaantal bestanden of mappen is bereikt. Verwijder er enkele en probeer het opnieuw.", + "error_too_deep": "Mappen kunnen niet zo diep worden genest.", + "error_invalid_name": "De server accepteert deze naam niet.", + "error_forbidden": "U hebt hier geen toestemming voor.", + "error_too_large": "Dit bestand is te groot voor de server.", "create_folder_success": "Map aangemaakt", "create_folder_error": "Map aanmaken mislukt", "delete_success": "Succesvol verwijderd", diff --git a/locales/pl/common.json b/locales/pl/common.json index 74f007677..8d3aa1e77 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3309,6 +3309,8 @@ "new_name": "Nowa nazwa", "name_forbidden_chars": "Serwer nie zezwala na te znaki w nazwach: {chars}", "name_reserved": "Ta nazwa jest zarezerwowana na serwerze.", + "name_control_chars": "Nazwy nie mogą zawierać znaków sterujących.", + "name_too_long": "Ta nazwa jest zbyt długa dla serwera.", "delete": "Usuń", "delete_confirm_title": "Usuń zasób", "delete_confirm_message": "Czy na pewno chcesz usunąć \"{name}\"? Tej operacji nie można cofnąć.", @@ -3326,6 +3328,12 @@ "uploading": "Przesyłanie...", "upload_success": "{count, plural, one {Przesłano 1 plik} other {Przesłano # plików}}", "upload_error": "Nie udało się przesłać pliku", + "upload_failed": "{count, plural, one {Nie udało się przesłać 1 pliku} few {Nie udało się przesłać # plików} many {Nie udało się przesłać # plików} other {Nie udało się przesłać # pliku}}", + "error_over_quota": "Osiągnięto limit plików lub folderów. Usuń niektóre i spróbuj ponownie.", + "error_too_deep": "Foldery nie mogą być zagnieżdżone tak głęboko.", + "error_invalid_name": "Serwer nie akceptuje tej nazwy.", + "error_forbidden": "Nie masz tutaj uprawnień do wykonania tej czynności.", + "error_too_large": "Ten plik jest zbyt duży dla serwera.", "create_folder_success": "Folder utworzono", "create_folder_error": "Nie udało się utworzyć folderu", "delete_success": "Usunięto pomyślnie", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8dc43780b..52c70e46f 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3309,6 +3309,8 @@ "new_name": "Novo nome", "name_forbidden_chars": "O servidor não permite estes caracteres nos nomes: {chars}", "name_reserved": "Este nome está reservado no servidor.", + "name_control_chars": "Os nomes não podem conter caracteres de controle.", + "name_too_long": "Este nome é longo demais para o servidor.", "delete": "Eliminar", "delete_confirm_title": "Eliminar recurso", "delete_confirm_message": "Tem a certeza de que deseja eliminar \"{name}\"? Esta ação não pode ser desfeita.", @@ -3326,6 +3328,12 @@ "uploading": "A carregar...", "upload_success": "{count, plural, one {1 arquivo carregado} other {# arquivos carregados}}", "upload_error": "Falha ao carregar o arquivo", + "upload_failed": "{count, plural, one {Não foi possível carregar 1 arquivo} other {Não foi possível carregar # arquivos}}", + "error_over_quota": "O limite de arquivos ou pastas foi atingido. Exclua alguns e tente novamente.", + "error_too_deep": "As pastas não podem ser aninhadas tão profundamente.", + "error_invalid_name": "O servidor não aceita este nome.", + "error_forbidden": "Não tem permissão para fazer isso aqui.", + "error_too_large": "Este arquivo é grande demais para o servidor.", "create_folder_success": "Pasta criada", "create_folder_error": "Falha ao criar a pasta", "delete_success": "Eliminado com sucesso", diff --git a/locales/ro/common.json b/locales/ro/common.json index 9f269d363..6d6ff276f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3354,6 +3354,8 @@ "new_name": "Nume nou", "name_forbidden_chars": "Serverul nu permite aceste caractere în nume: {chars}", "name_reserved": "Acest nume este rezervat pe server.", + "name_control_chars": "Numele nu pot conține caractere de control.", + "name_too_long": "Acest nume este prea lung pentru server.", "delete": "Șterge", "delete_confirm_title": "Ștergeți resursa", "delete_confirm_message": "Sunteți sigur că doriți să ștergeți „{name}”? Această acțiune nu poate fi anulată.", @@ -3373,6 +3375,12 @@ "uploading": "Se încarcă...", "upload_success": "{count, plural, one {1 fișier încărcat} few {# fișiere încărcate} other {# de fișiere încărcate}}", "upload_error": "Nu s-a reușit încărcarea fișierului", + "upload_failed": "{count, plural, one {1 fișier nu a putut fi încărcat} few {# fișiere nu au putut fi încărcate} other {# de fișiere nu au putut fi încărcate}}", + "error_over_quota": "Ați atins limita de fișiere sau foldere. Ștergeți câteva și încercați din nou.", + "error_too_deep": "Folderele nu pot fi imbricate atât de adânc.", + "error_invalid_name": "Serverul nu acceptă acest nume.", + "error_forbidden": "Nu aveți permisiunea de a face acest lucru aici.", + "error_too_large": "Acest fișier este prea mare pentru server.", "create_folder_success": "Folder creat", "create_folder_error": "Nu s-a putut crea folderul", "delete_success": "Șters cu succes", diff --git a/locales/ru/common.json b/locales/ru/common.json index ef7af2a02..6ae734cc3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3309,6 +3309,8 @@ "new_name": "Новое название", "name_forbidden_chars": "Сервер не допускает эти символы в именах: {chars}", "name_reserved": "Это имя зарезервировано на сервере.", + "name_control_chars": "Имена не могут содержать управляющие символы.", + "name_too_long": "Это имя слишком длинное для сервера.", "delete": "Удалить", "delete_confirm_title": "Удалить ресурс", "delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.", @@ -3326,6 +3328,12 @@ "uploading": "Загрузка...", "upload_success": "{count, plural, one {1 файл загружен} other {# файлов загружено}}", "upload_error": "Не удалось загрузить файл", + "upload_failed": "{count, plural, one {Не удалось загрузить # файл} few {Не удалось загрузить # файла} many {Не удалось загрузить # файлов} other {Не удалось загрузить # файла}}", + "error_over_quota": "Достигнуто ограничение на число файлов или папок. Удалите некоторые и повторите попытку.", + "error_too_deep": "Папки нельзя вкладывать так глубоко.", + "error_invalid_name": "Сервер не принимает это имя.", + "error_forbidden": "У вас нет прав на это действие здесь.", + "error_too_large": "Этот файл слишком большой для сервера.", "create_folder_success": "Папка создана", "create_folder_error": "Не удалось создать папку", "delete_success": "Успешно удалено", diff --git a/locales/sk/common.json b/locales/sk/common.json index 0c4209608..045396c7b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3354,6 +3354,8 @@ "new_name": "Nový názov", "name_forbidden_chars": "Server nepovoľuje v názvoch tieto znaky: {chars}", "name_reserved": "Tento názov je na serveri vyhradený.", + "name_control_chars": "Názvy nesmú obsahovať riadiace znaky.", + "name_too_long": "Tento názov je pre server príliš dlhý.", "delete": "Odstrániť", "delete_confirm_title": "Odstrániť položku", "delete_confirm_message": "Naozaj chcete odstrániť \"{name}\"? Túto akciu nie je možné vrátiť.", @@ -3373,6 +3375,12 @@ "uploading": "Nahrávanie...", "upload_success": "{count, plural, one {1 súbor nahraný} other {# súborov nahraných}}", "upload_error": "Nahranie súboru zlyhalo", + "upload_failed": "{count, plural, one {1 súbor sa nepodarilo nahrať} few {# súbory sa nepodarilo nahrať} other {# súborov sa nepodarilo nahrať}}", + "error_over_quota": "Dosiahli ste limit počtu súborov alebo priečinkov. Niektoré odstráňte a skúste to znova.", + "error_too_deep": "Priečinky nemožno vnoriť tak hlboko.", + "error_invalid_name": "Server tento názov neprijíma.", + "error_forbidden": "Na túto akciu tu nemáte oprávnenie.", + "error_too_large": "Tento súbor je pre server príliš veľký.", "create_folder_success": "Priečinok bol vytvorený", "create_folder_error": "Vytvorenie priečinka zlyhalo", "delete_success": "Úspešne odstránené", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f0de6a67..4bb07c8f0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3354,6 +3354,8 @@ "new_name": "Yeni ad", "name_forbidden_chars": "Sunucu adlarda şu karakterlere izin vermiyor: {chars}", "name_reserved": "Bu ad sunucuda ayrılmış.", + "name_control_chars": "Adlar denetim karakterleri içeremez.", + "name_too_long": "Bu ad sunucu için çok uzun.", "delete": "Sil", "delete_confirm_title": "Kaynağı sil", "delete_confirm_message": "\"{name}\" silinsin mi? Bu geri alınamaz.", @@ -3371,6 +3373,12 @@ "uploading": "Yükleniyor...", "upload_success": "{count, plural, one {1 dosya yüklendi} other {# dosya yüklendi}}", "upload_error": "Dosya yüklenemedi", + "upload_failed": "{count, plural, one {1 dosya yüklenemedi} other {# dosya yüklenemedi}}", + "error_over_quota": "Dosya veya klasör sınırına ulaştınız. Bazılarını silip yeniden deneyin.", + "error_too_deep": "Klasörler bu kadar derin iç içe yerleştirilemez.", + "error_invalid_name": "Sunucu bu adı kabul etmiyor.", + "error_forbidden": "Burada bunu yapma izniniz yok.", + "error_too_large": "Bu dosya sunucu için çok büyük.", "create_folder_success": "Klasör oluşturuldu", "create_folder_error": "Klasör oluşturulamadı", "delete_success": "Başarıyla silindi", diff --git a/locales/uk/common.json b/locales/uk/common.json index a5b795e48..e1be69e19 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3309,6 +3309,8 @@ "new_name": "Нова назва", "name_forbidden_chars": "Сервер не дозволяє ці символи в назвах: {chars}", "name_reserved": "Ця назва зарезервована на сервері.", + "name_control_chars": "Назви не можуть містити керівні символи.", + "name_too_long": "Ця назва задовга для сервера.", "delete": "Видалити", "delete_confirm_title": "Видалити ресурс", "delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.", @@ -3326,6 +3328,12 @@ "uploading": "Завантаження...", "upload_success": "{count, plural, one {1 файл завантажено} few {# файли завантажено} many {# файлів завантажено} other {# файлів завантажено}}", "upload_error": "Не вдалося завантажити файл", + "upload_failed": "{count, plural, one {Не вдалося завантажити # файл} few {Не вдалося завантажити # файли} many {Не вдалося завантажити # файлів} other {Не вдалося завантажити # файлу}}", + "error_over_quota": "Досягнуто обмеження на кількість файлів або папок. Видаліть деякі й повторіть спробу.", + "error_too_deep": "Папки не можна вкладати так глибоко.", + "error_invalid_name": "Сервер не приймає цю назву.", + "error_forbidden": "У вас немає дозволу на цю дію тут.", + "error_too_large": "Цей файл завеликий для сервера.", "create_folder_success": "Папка створена", "create_folder_error": "Не вдалося створити папку", "delete_success": "Успішно видалено", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 9e1307fe5..bd2e3cea7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3354,6 +3354,8 @@ "new_name": "新名稱", "name_forbidden_chars": "伺服器不允許名稱中包含這些字元:{chars}", "name_reserved": "此名稱已被伺服器保留。", + "name_control_chars": "名稱不能包含控制字元。", + "name_too_long": "此名稱對伺服器來說過長。", "delete": "刪除", "delete_confirm_title": "刪除項目", "delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。", @@ -3373,6 +3375,12 @@ "uploading": "正在上傳…", "upload_success": "{count, plural, one {已上傳 1 個檔案} other {已上傳 # 個檔案}}", "upload_error": "上傳檔案失敗", + "upload_failed": "{count, plural, one {1 個檔案無法上傳} other {# 個檔案無法上傳}}", + "error_over_quota": "已達到檔案或資料夾數量上限。請刪除一些後再試一次。", + "error_too_deep": "資料夾不能巢狀這麼深。", + "error_invalid_name": "伺服器不接受此名稱。", + "error_forbidden": "你沒有權限在此處執行此操作。", + "error_too_large": "此檔案對伺服器來說過大。", "create_folder_success": "資料夾已建立", "create_folder_error": "建立資料夾失敗", "delete_success": "刪除成功", diff --git a/locales/zh/common.json b/locales/zh/common.json index c02692c38..7417206e7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3309,6 +3309,8 @@ "new_name": "新名称", "name_forbidden_chars": "服务器不允许名称中包含这些字符:{chars}", "name_reserved": "此名称已被服务器保留。", + "name_control_chars": "名称不能包含控制字符。", + "name_too_long": "此名称对服务器来说过长。", "delete": "删除", "delete_confirm_title": "删除资源", "delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。", @@ -3326,6 +3328,12 @@ "uploading": "正在上传…", "upload_success": "{count, plural, one {已上传 1 个文件} other {已上传 # 个文件}}", "upload_error": "上传文件失败", + "upload_failed": "{count, plural, one {1 个文件未能上传} other {# 个文件未能上传}}", + "error_over_quota": "已达到文件或文件夹数量上限。请删除一些后重试。", + "error_too_deep": "文件夹不能嵌套这么深。", + "error_invalid_name": "服务器不接受此名称。", + "error_forbidden": "你无权在此处执行此操作。", + "error_too_large": "此文件对服务器来说过大。", "create_folder_success": "文件夹已创建", "create_folder_error": "创建文件夹失败", "delete_success": "删除成功", diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index 64e2fee78..d7b2c5bd8 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useFileStore, resourceServerRef } from '../file-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode } from '@/lib/jmap/types'; +import { jmapError } from '@/lib/jmap/jmap-error'; +import { fileErrorKind } from '@/lib/file-errors'; // Minimal in-memory FileNode backend that models the JMAP hierarchy via // parentId links (the behaviour issue #379 asks for). @@ -528,6 +530,112 @@ describe('file-store symlinks (Stalwart 1.0)', () => { }); }); +describe('file-store upload batches', () => { + // A file dropped as part of a folder carries its path inside it. + const inFolder = (path: string) => { + const file = new File(['x'], path.split('/').pop()!, { type: 'text/plain' }); + Object.defineProperty(file, 'webkitRelativePath', { value: path }); + return file; + }; + + beforeEach(() => { + useFileStore.setState({ + client: null, + currentParentId: null, + currentPath: '/', + pathStack: [{ id: null, name: '' }], + resources: [], + selectedResources: new Set(), + clipboard: null, + lastAction: null, + }); + }); + + it('goes on past a file the server refuses and reports it at the end', async () => { + const client = makeMockClient([]); + const createFileNode = client.createFileNode.bind(client); + Object.assign(client, { + createFileNode: async (name: string, ...rest: [string, string, number, string | null]) => { + if (name === 'b.txt') { + // 1.0 once an administrator has set maxFiles. + throw jmapError('There are too many files, please delete some before adding a new one.', { type: 'overQuota' }); + } + return createFileNode(name, ...rest); + }, + }); + useFileStore.getState().initClient(client); + + const report = await useFileStore.getState().uploadFiles( + ['a.txt', 'b.txt', 'c.txt'].map(n => new File(['x'], n, { type: 'text/plain' })), + ); + + expect(report.uploaded).toBe(2); + expect(report.failed.map(f => f.name)).toEqual(['b.txt']); + expect(fileErrorKind(report.failed[0].error)).toBe('overQuota'); + expect(client._nodes().map(n => n.name)).toEqual(['a.txt', 'c.txt']); + }); + + it('skips the files of a folder it could not create instead of putting them in the upload\'s folder', async () => { + const client = makeMockClient([dir('here', 'Here', null)]); + const createFileDirectory = client.createFileDirectory.bind(client); + Object.assign(client, { + createFileDirectory: async (name: string, parentId: string | null) => { + if (name === 'Locked') { + throw jmapError('You are not allowed to create file nodes in this folder.', { type: 'forbidden' }); + } + return createFileDirectory(name, parentId); + }, + }); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate('here', 'Here'); + + const report = await useFileStore.getState().uploadFolder([ + inFolder('Photos/a.jpg'), + inFolder('Photos/Locked/b.jpg'), + inFolder('Photos/Locked/Inner/c.jpg'), + ]); + + expect(report.uploaded).toBe(1); + expect(report.failed.map(f => f.name)).toEqual(['Photos/Locked/b.jpg', 'Photos/Locked/Inner/c.jpg']); + expect(report.failed.every(f => fileErrorKind(f.error) === 'forbidden')).toBe(true); + + const nodes = client._nodes(); + const photos = nodes.find(n => n.name === 'Photos')!; + expect(nodes.filter(n => n.parentId === 'here').map(n => n.name)).toEqual(['Photos']); + expect(nodes.filter(n => n.parentId === photos.id).map(n => n.name)).toEqual(['a.jpg']); + }); + + it('leaves out what would sit deeper than maxFileNodeDepth (Stalwart 1.0)', async () => { + // Uploading into Stuff/Nonsense, level 2, with a limit of 4 levels. + const client = makeMockClient([dir('stuff', 'Stuff', null), dir('nonsense', 'Nonsense', 'stuff')]); + Object.assign(client, { getMaxFileNodeDepth: () => 4 }); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate('stuff', 'Stuff'); + await useFileStore.getState().navigate('nonsense', 'Nonsense'); + + const report = await useFileStore.getState().uploadFolder([ + inFolder('Up/a.txt'), + inFolder('Up/Deep/b.txt'), + inFolder('Up/Deep/Deeper/c.txt'), + ]); + + expect(report.uploaded).toBe(1); + expect(report.failed.map(f => f.name)).toEqual(['Up/Deep/b.txt', 'Up/Deep/Deeper/c.txt']); + expect(report.failed.every(f => fileErrorKind(f.error) === 'tooDeep')).toBe(true); + expect(client._nodes().map(n => n.name)).toEqual(['Stuff', 'Nonsense', 'Up', 'Deep', 'a.txt']); + }); + + it('has no depth to check on 0.16', async () => { + const client = makeMockClient([dir('stuff', 'Stuff', null), dir('nonsense', 'Nonsense', 'stuff')]); + Object.assign(client, { getMaxFileNodeDepth: () => null }); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate('nonsense', 'Nonsense'); + + const report = await useFileStore.getState().uploadFolder([inFolder('Up/Deep/Deeper/c.txt')]); + expect(report).toEqual({ uploaded: 1, failed: [] }); + }); +}); + describe('resourceServerRef', () => { it('splits a shared drive\'s namespaced id into the owner account and the node id', () => { expect(resourceServerRef({ id: 'owner1:n5', isShared: true, ownerAccountId: 'owner1' })) diff --git a/stores/file-store.ts b/stores/file-store.ts index b5d0f4293..8dc17ac8c 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; import { fileNodeKind } from '@/lib/jmap/filenode-kind'; +import { fileNodeDepth } from '@/lib/jmap/filenode-depth'; +import { folderDepthError } from '@/lib/file-errors'; export interface FileResource { id: string; @@ -34,6 +36,15 @@ interface UploadProgress { totalFiles: number; } +/** + * What an upload batch did. A file the server refuses doesn't stop the rest; + * it is listed here (by its path inside an uploaded folder) with the error. + */ +export interface UploadReport { + uploaded: number; + failed: { name: string; error: unknown }[]; +} + interface ClipboardState { mode: 'cut' | 'copy'; ids: string[]; @@ -90,8 +101,8 @@ interface FileState { refresh: () => Promise; createDirectory: (name: string) => Promise; uploadFile: (file: File) => Promise; - uploadFiles: (files: File[]) => Promise; - uploadFolder: (files: File[]) => Promise; + uploadFiles: (files: File[]) => Promise; + uploadFolder: (files: File[]) => Promise; cancelUpload: () => void; deleteResource: (name: string) => Promise; deleteResources: (names: string[]) => Promise; @@ -637,7 +648,8 @@ export const useFileStore = create((set, get) => ({ uploadFiles: async (files: File[]) => { const { client, currentParentId, resources } = get(); - if (!client) return; + const report: UploadReport = { uploaded: 0, failed: [] }; + if (!client) return report; const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -662,14 +674,15 @@ export const useFileStore = create((set, get) => ({ if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + report.uploaded++; } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; - set({ uploadProgress: null, uploadAbortController: null }); - throw err; + report.failed.push({ name: file.name, error: err }); } } set({ uploadProgress: null, uploadAbortController: null }); await get().refresh(); + return report; }, cancelUpload: () => { @@ -682,41 +695,68 @@ export const useFileStore = create((set, get) => ({ uploadFolder: async (files: File[]) => { const { client, currentParentId } = get(); - if (!client || files.length === 0) return; + const report: UploadReport = { uploaded: 0, failed: [] }; + if (!client || files.length === 0) return report; const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; + const relativePathOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + const levelsOf = (path: string) => (path ? path.split('/').length : 0); // Collect unique directory paths (relative to the dropped folder) and // create them as real nested directories, mapping each path to its node id. const dirs = new Set(); for (const file of files) { - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; - const parts = relativePath.split('/'); + const parts = relativePathOf(file).split('/'); for (let i = 1; i < parts.length; i++) { dirs.add(parts.slice(0, i).join('/')); } } - // Map a directory path to its created node id. Root ('') maps to the - // current folder we are uploading into. - const dirIds = new Map(); - dirIds.set('', currentParentId); + // Stalwart 1.0 refuses nodes deeper than maxFileNodeDepth (64). Find out + // up front what won't fit below the current folder. + const maxDepth = client.getMaxFileNodeDepth?.() ?? null; + let baseDepth = 0; + if (maxDepth !== null && currentParentId !== null) { + try { + const nodes = isCrossAccountId(currentParentId) + ? await client.listAllFileNodesAcrossAccounts() + : await client.listAllFileNodes(); + baseDepth = fileNodeDepth(nodes, currentParentId); + } catch { + // The server still refuses what doesn't fit. + } + } + const tooDeep = (levels: number) => maxDepth !== null && baseDepth + levels > maxDepth; - const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length); + // Map a directory path to its created node id. Root ('') maps to the + // current folder we are uploading into. A folder that can't be created + // keeps its error, and everything inside it is skipped with that error + // instead of landing in the folder the upload started in. + const dirIds = new Map([['', currentParentId]]); + const dirErrors = new Map(); + + const sortedDirs = [...dirs].sort((a, b) => levelsOf(a) - levelsOf(b)); for (const dir of sortedDirs) { if (abortController.signal.aborted) break; const slash = dir.lastIndexOf('/'); const parentPath = slash >= 0 ? dir.slice(0, slash) : ''; const dirName = slash >= 0 ? dir.slice(slash + 1) : dir; - const parentId = dirIds.get(parentPath) ?? currentParentId; + const parentId = dirIds.get(parentPath); + if (parentId === undefined) { + dirErrors.set(dir, dirErrors.get(parentPath)); + continue; + } + if (tooDeep(levelsOf(dir))) { + dirErrors.set(dir, folderDepthError()); + continue; + } try { const created = await client.createFileDirectory(dirName, parentId); dirIds.set(dir, created.id); - } catch { - // Directory may already exist - leave it unmapped; files fall back to - // the closest known parent below. + } catch (err) { + dirErrors.set(dir, err); } } @@ -724,10 +764,18 @@ export const useFileStore = create((set, get) => ({ for (let i = 0; i < files.length; i++) { if (abortController.signal.aborted) break; const file = files[i]; - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + const relativePath = relativePathOf(file); const slash = relativePath.lastIndexOf('/'); const dirPath = slash >= 0 ? relativePath.slice(0, slash) : ''; - const parentId = dirIds.get(dirPath) ?? currentParentId; + const parentId = dirIds.get(dirPath); + if (parentId === undefined) { + report.failed.push({ name: relativePath, error: dirErrors.get(dirPath) }); + continue; + } + if (tooDeep(levelsOf(dirPath) + 1)) { + report.failed.push({ name: relativePath, error: folderDepthError() }); + continue; + } set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } }); @@ -736,14 +784,15 @@ export const useFileStore = create((set, get) => ({ if (abortController.signal.aborted) break; set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); + report.uploaded++; } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; - set({ uploadProgress: null, uploadAbortController: null }); - throw err; + report.failed.push({ name: relativePath, error: err }); } } set({ uploadProgress: null, uploadAbortController: null }); await get().refresh(); + return report; }, deleteResource: async (name: string) => {