diff --git a/src/api/__tests__/blob.test.ts b/src/api/__tests__/blob.test.ts index ac612cc6..26fec2bd 100644 --- a/src/api/__tests__/blob.test.ts +++ b/src/api/__tests__/blob.test.ts @@ -7,6 +7,23 @@ vi.mock('expo-file-system', () => ({ }, })); +// The streaming upload path is mocked as a controllable task so the tests +// can drive progress, completion and cancellation. +const uploadTask = { + uploadAsync: vi.fn(), + cancelAsync: vi.fn(async () => undefined), +}; +const createUploadTask = vi.fn((..._args: unknown[]) => uploadTask); +vi.mock('expo-file-system/legacy', () => ({ + createUploadTask: (...args: unknown[]) => createUploadTask(...args), + FileSystemUploadType: { BINARY_CONTENT: 0, MULTIPART: 1 }, +})); + +vi.mock('../../lib/client-cert', () => ({ + getClientCertAlias: vi.fn(async () => null), + secureFetch: vi.fn(), +})); + vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', @@ -18,7 +35,7 @@ vi.mock('../jmap-client', () => ({ }, })); -import { getDownloadUrl } from '../blob'; +import { getDownloadUrl, uploadBlob } from '../blob'; beforeEach(() => { vi.clearAllMocks(); @@ -49,3 +66,63 @@ describe('blob operations', () => { }); }); }); + +describe('uploadBlob', () => { + it('streams the file through a native upload task with auth headers and reports progress', async () => { + let progressCb: ((d: { totalBytesSent: number; totalBytesExpectedToSend: number }) => void) | undefined; + createUploadTask.mockImplementationOnce((...args: unknown[]) => { + progressCb = args[3] as typeof progressCb; + return uploadTask; + }); + uploadTask.uploadAsync.mockImplementationOnce(async () => { + progressCb?.({ totalBytesSent: 50, totalBytesExpectedToSend: 100 }); + return { status: 201, body: JSON.stringify({ blobId: 'blob-1', size: 100, type: 'image/png' }) }; + }); + const onProgress = vi.fn(); + + const result = await uploadBlob('file:///tmp/a.png', 'image/png', { onProgress }); + + expect(result).toEqual({ blobId: 'blob-1', size: 100, type: 'image/png' }); + expect(onProgress).toHaveBeenCalledWith(50, 100); + const [url, uri, options] = createUploadTask.mock.calls[0] as unknown as [string, string, { headers: Record; httpMethod: string }]; + expect(url).toBe('https://mail.example.com/upload/acc-1/'); + expect(uri).toBe('file:///tmp/a.png'); + expect(options.httpMethod).toBe('POST'); + expect(options.headers).toEqual({ 'Content-Type': 'image/png', Authorization: 'Basic dXNlcjpwYXNz' }); + }); + + it('accepts the per-account nested response shape', async () => { + uploadTask.uploadAsync.mockResolvedValueOnce({ + status: 200, + body: JSON.stringify({ 'acc-1': { blobId: 'blob-2', size: 7, type: 'text/plain' } }), + }); + const result = await uploadBlob('file:///tmp/a.txt', 'text/plain'); + expect(result.blobId).toBe('blob-2'); + }); + + it('surfaces HTTP failures with the status code', async () => { + uploadTask.uploadAsync.mockResolvedValueOnce({ status: 413, body: 'too large' }); + await expect(uploadBlob('file:///tmp/a.bin', 'application/octet-stream')).rejects.toThrow(/413/); + }); + + it('cancels the native task when the signal aborts', async () => { + const controller = new AbortController(); + uploadTask.uploadAsync.mockImplementationOnce(async () => { + controller.abort(); + return null; + }); + await expect( + uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(uploadTask.cancelAsync).toHaveBeenCalled(); + }); + + it('rejects immediately when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(createUploadTask).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts index ad29521f..1f594712 100644 --- a/src/api/__tests__/files.test.ts +++ b/src/api/__tests__/files.test.ts @@ -5,6 +5,8 @@ vi.mock('../jmap-client', () => ({ accountId: 'acc-1', request: vi.fn(), hasCapability: vi.fn(), + hasAccountCapability: vi.fn(() => false), + getMaxSizeUpload: vi.fn(() => 0), currentSession: null as unknown, }, })); @@ -21,14 +23,19 @@ vi.mock('../blob', () => ({ import { jmapClient } from '../jmap-client'; import { CAPABILITIES } from '../types'; import { + accountSupportsFiles, + copyFileNode, createFolder, deleteFileNodes, + getAllFileNodes, getAllFileNodesAcrossAccounts, getFileNodeDownloadUrl, getPrincipals, isCrossAccountId, isFolder, + moveFileNode, setFileNodeShare, + supportsSharing, } from '../files'; const mockRequest = jmapClient.request as ReturnType; @@ -41,7 +48,10 @@ function setSession(session: unknown) { beforeEach(() => { vi.clearAllMocks(); mockHasCapability.mockImplementation( - (urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS, + (urn: string) => + urn === CAPABILITIES.FILES || + urn === CAPABILITIES.PRINCIPALS || + urn === CAPABILITIES.PRINCIPALS_OWNER, ); setSession({ primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' }, @@ -250,3 +260,100 @@ describe('getPrincipals', () => { expect(mockRequest).not.toHaveBeenCalled(); }); }); + +describe('decodeFileNodeName at the API boundary (#869)', () => { + it('decodes percent-encoded names from getAllFileNodes and the cross-account list', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/get', { + list: [ + { id: 'n1', name: 'Spares%20Catalog', blobId: null }, + { id: 'n2', name: '100% done.txt', blobId: 'b2' }, + ], + }, '0']], + }); + + const own = await getAllFileNodes(); + expect(own.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']); + + const all = await getAllFileNodesAcrossAccounts(); + expect(all.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']); + }); + + it('requests the real "modified" property, not "updated" (#700)', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['FileNode/get', { list: [] }, '0']] }); + await getAllFileNodes(); + const [, args] = mockRequest.mock.calls[0][0][0]; + expect(args.properties).toContain('modified'); + expect(args.properties).not.toContain('updated'); + }); +}); + +describe('copyFileNode', () => { + it('creates a new node that reuses the blob instead of re-uploading', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'copy-1' } } }, '0']], + }); + + const copy = await copyFileNode( + { id: 'f1', name: 'report.pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1' }, + 'dir-1', + 'report (1).pdf', + ); + + const [method, args] = mockRequest.mock.calls[0][0][0]; + expect(method).toBe('FileNode/set'); + expect(args.create['new-file']).toEqual({ + name: 'report (1).pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1', + }); + expect(copy.id).toBe('copy-1'); + }); + + it('refuses to duplicate folders', async () => { + await expect(copyFileNode({ id: 'd', name: 'Docs', type: '', blobId: null }, null)).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +describe('moveFileNode', () => { + it('sends an explicit null parentId when moving to the root', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', { updated: { f1: null } }, '0']] }); + await moveFileNode('f1', null); + const [, args] = mockRequest.mock.calls[0][0][0]; + expect(args.update.f1).toEqual({ parentId: null }); + }); +}); + +describe('accountSupportsFiles (#563)', () => { + const caps = { [CAPABILITIES.FILES]: {} }; + it('requires the account capability on personal accounts', () => { + expect(accountSupportsFiles({ name: 'me', isPersonal: true, isReadOnly: false }, caps)).toBe(false); + expect(accountSupportsFiles( + { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.FILES]: {} } }, + caps, + )).toBe(true); + }); + + it('treats non-personal accounts as capable and needs the session capability', () => { + expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, caps)).toBe(true); + expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false); + expect(accountSupportsFiles(undefined, caps)).toBe(false); + }); +}); + +describe('supportsSharing', () => { + it('accepts principals:owner from the session or the account capabilities', () => { + const mockHasAccountCapability = jmapClient.hasAccountCapability as ReturnType; + mockHasCapability.mockImplementation((urn: string) => urn === CAPABILITIES.FILES); + mockHasAccountCapability.mockReturnValue(false); + expect(supportsSharing()).toBe(false); + + mockHasAccountCapability.mockImplementation((urn: string) => urn === CAPABILITIES.PRINCIPALS_OWNER); + expect(supportsSharing()).toBe(true); + + mockHasAccountCapability.mockReturnValue(false); + mockHasCapability.mockImplementation( + (urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS_OWNER, + ); + expect(supportsSharing()).toBe(true); + }); +}); diff --git a/src/api/blob.ts b/src/api/blob.ts index c9c6b648..2739c6f7 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -1,64 +1,172 @@ import { File } from 'expo-file-system'; +import type * as LegacyFileSystemTypes from 'expo-file-system/legacy'; import { jmapClient } from './jmap-client'; -import { secureFetch } from '../lib/client-cert'; +import { getClientCertAlias, secureFetch } from '../lib/client-cert'; +// The legacy expo-file-system API (streaming upload tasks with progress and +// cancellation) is required lazily: it touches native globals at import time, +// which would break every module that merely imports this file in unit tests. +type LegacyFileSystemModule = typeof LegacyFileSystemTypes; +let legacyFileSystem: LegacyFileSystemModule | null = null; +async function loadLegacyFileSystem(): Promise { + if (!legacyFileSystem) { + legacyFileSystem = (await import('expo-file-system/legacy')) as LegacyFileSystemModule; + } + return legacyFileSystem; +} + +export interface UploadBlobOptions { + /** Called with bytes sent / total as the upload streams. */ + onProgress?: (sent: number, total: number) => void; + /** Abort the in-flight upload; the promise then rejects with an AbortError. */ + signal?: AbortSignal; +} + +interface UploadResult { + blobId: string; + size: number; + type: string; +} + +function abortError(): Error { + const err = new Error('Upload cancelled'); + err.name = 'AbortError'; + return err; +} + +// JMAP servers return either the direct shape `{blobId, type, size}` or +// the per-account nested shape `{[accountId]: {blobId, type, size}}`. +// Stalwart has shipped both depending on version; mirror the webmail's +// tolerant parsing so uploads don't silently lose the blobId. +function parseUploadResponse( + raw: Record, + accountId: string, + fallbackSize: number, + fallbackType: string, +): UploadResult { + const direct = raw as { blobId?: string; type?: string; size?: number }; + if (typeof direct.blobId === 'string') { + return { + blobId: direct.blobId, + size: typeof direct.size === 'number' ? direct.size : fallbackSize, + type: typeof direct.type === 'string' && direct.type ? direct.type : fallbackType, + }; + } + const nested = raw[accountId] as + | { blobId?: string; type?: string; size?: number } + | undefined; + if (nested?.blobId) { + return { + blobId: nested.blobId, + size: typeof nested.size === 'number' ? nested.size : fallbackSize, + type: typeof nested.type === 'string' && nested.type ? nested.type : fallbackType, + }; + } + throw new Error('Upload succeeded but response did not include a blobId'); +} + +function uploadUrlFor(accountId: string): string { + const session = jmapClient.currentSession; + if (!session) throw new Error('Not connected'); + return session.uploadUrl.replace('{accountId}', encodeURIComponent(accountId)); +} + +// Upload a local file to the JMAP upload endpoint. +// +// The default path streams the file from disk through expo-file-system's +// native upload task, which reports progress and can be cancelled, so large +// videos/PDFs from the document picker no longer have to fit in JS memory +// (webmail #162/#333). When the user has picked a client certificate the +// native task can't present it, so we fall back to buffering the bytes and +// posting them through the cert-aware fetch. export async function uploadBlob( uri: string, type: string, -): Promise<{ blobId: string; size: number; type: string }> { - const session = jmapClient.currentSession; - if (!session) throw new Error('Not connected'); + options: UploadBlobOptions = {}, +): Promise { + const accountId = jmapClient.accountId; + const uploadUrl = uploadUrlFor(accountId); + const contentType = type || 'application/octet-stream'; + const { onProgress, signal } = options; + if (signal?.aborted) throw abortError(); - const uploadUrl = session.uploadUrl.replace( - '{accountId}', - encodeURIComponent(jmapClient.accountId), + const alias = await getClientCertAlias(); + if (alias) { + return uploadBlobBuffered(uri, contentType, accountId, uploadUrl, signal); + } + + const LegacyFileSystem = await loadLegacyFileSystem(); + const task = LegacyFileSystem.createUploadTask( + uploadUrl, + uri, + { + httpMethod: 'POST', + uploadType: LegacyFileSystem.FileSystemUploadType.BINARY_CONTENT, + headers: { + 'Content-Type': contentType, + Authorization: jmapClient.authHeader, + }, + }, + onProgress + ? (data) => onProgress(data.totalBytesSent, data.totalBytesExpectedToSend) + : undefined, ); + const onAbort = () => void task.cancelAsync(); + signal?.addEventListener('abort', onAbort); + let result: LegacyFileSystemTypes.FileSystemUploadResult | null | undefined; + try { + result = await task.uploadAsync(); + } finally { + signal?.removeEventListener('abort', onAbort); + } + if (signal?.aborted || !result) throw abortError(); + + if (result.status < 200 || result.status >= 300) { + const detail = (result.body || '').slice(0, 300); + throw new Error(`Upload failed: ${result.status}${detail ? ` ${detail}` : ''}`); + } + + let raw: Record; + try { + raw = JSON.parse(result.body) as Record; + } catch { + throw new Error('Upload succeeded but the server response was not JSON'); + } + return parseUploadResponse(raw, accountId, 0, contentType); +} + +async function uploadBlobBuffered( + uri: string, + contentType: string, + accountId: string, + uploadUrl: string, + signal?: AbortSignal, +): Promise { // Read via the file-system API so this works for both `file://` (image // picker, iOS document picker) and `content://` (Android SAF) URIs. We pass // the underlying ArrayBuffer rather than the Uint8Array view because some // RN transports stringify typed-array bodies, which silently produces an // empty/garbage upload. const bytes = await new File(uri).bytes(); + if (signal?.aborted) throw abortError(); const response = await secureFetch(uploadUrl, { method: 'POST', headers: { - 'Content-Type': type || 'application/octet-stream', + 'Content-Type': contentType, Authorization: jmapClient.authHeader, }, body: bytes.buffer as ArrayBuffer, + signal, }); if (!response.ok) { const detail = await response.text().catch(() => ''); throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`); } - - // JMAP servers return either the direct shape `{blobId, type, size}` or - // the per-account nested shape `{[accountId]: {blobId, type, size}}`. - // Stalwart has shipped both depending on version; mirror the webmail's - // tolerant parsing so uploads don't silently lose the blobId. const raw = (await response.json()) as Record; - const direct = raw as { blobId?: string; type?: string; size?: number }; - if (typeof direct.blobId === 'string') { - return { - blobId: direct.blobId, - size: typeof direct.size === 'number' ? direct.size : bytes.byteLength, - type: typeof direct.type === 'string' && direct.type ? direct.type : type, - }; - } - const nested = raw[jmapClient.accountId] as - | { blobId?: string; type?: string; size?: number } - | undefined; - if (nested?.blobId) { - return { - blobId: nested.blobId, - size: typeof nested.size === 'number' ? nested.size : bytes.byteLength, - type: typeof nested.type === 'string' && nested.type ? nested.type : type, - }; - } - throw new Error('Upload succeeded but response did not include a blobId'); + return parseUploadResponse(raw, accountId, bytes.byteLength, contentType); } // Direct in-memory upload. The on-disk variant goes through expo-file-system's @@ -70,15 +178,9 @@ export async function uploadBytes( // Blobs are account-scoped: a message imported into a shared/group account's // folder has to be uploaded to that account, not the user's own. accountId?: string, -): Promise<{ blobId: string; size: number; type: string }> { - const session = jmapClient.currentSession; - if (!session) throw new Error('Not connected'); - +): Promise { const targetAccountId = accountId ?? jmapClient.accountId; - const uploadUrl = session.uploadUrl.replace( - '{accountId}', - encodeURIComponent(targetAccountId), - ); + const uploadUrl = uploadUrlFor(targetAccountId); const response = await secureFetch(uploadUrl, { method: 'POST', @@ -95,25 +197,7 @@ export async function uploadBytes( } const raw = (await response.json()) as Record; - const direct = raw as { blobId?: string; type?: string; size?: number }; - if (typeof direct.blobId === 'string') { - return { - blobId: direct.blobId, - size: typeof direct.size === 'number' ? direct.size : bytes.byteLength, - type: typeof direct.type === 'string' && direct.type ? direct.type : type, - }; - } - const nested = raw[targetAccountId] as - | { blobId?: string; type?: string; size?: number } - | undefined; - if (nested?.blobId) { - return { - blobId: nested.blobId, - size: typeof nested.size === 'number' ? nested.size : bytes.byteLength, - type: typeof nested.type === 'string' && nested.type ? nested.type : type, - }; - } - throw new Error('Upload succeeded but response did not include a blobId'); + return parseUploadResponse(raw, targetAccountId, bytes.byteLength, type); } export function getDownloadUrl( diff --git a/src/api/files.ts b/src/api/files.ts index fcda49ed..968f0984 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,7 +1,8 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { FileNode, FileNodeRights, Principal } from './types'; -import { getDownloadUrl, uploadBlob } from './blob'; +import type { FileNode, FileNodeRights, JMAPAccountInfo, Principal } from './types'; +import { getDownloadUrl, uploadBlob, type UploadBlobOptions } from './blob'; +import { decodeFileNodeName } from '../lib/filenode-name'; // A FileNode is a folder (container) only when it has no blob content — the // server stores it with `file == null`. Sending a blobId/type/size on create @@ -15,22 +16,58 @@ export function isFolder(node: Pick): boolean { // Stalwart omits shareWith/myRights from FileNode/get unless they are // requested explicitly, so the share sheet and indicators must name them here. +// The modification timestamp is `modified` (RFC draft-ietf-jmap-filenode); +// asking for a property the server doesn't know silently yields undefined, +// which is how "updated" left every date blank (#700). const FILE_NODE_PROPERTIES = [ - 'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated', + 'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', 'shareWith', 'myRights', ]; +// RFC 9670 sharing is only usable when the server advertises the exact +// `principals:owner` capability, either in the session or on the account +// (Stalwart places it in accountCapabilities). export function supportsSharing(): boolean { - return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS); + return ( + jmapClient.hasCapability(CAPABILITIES.PRINCIPALS_OWNER) || + jmapClient.hasAccountCapability(CAPABILITIES.PRINCIPALS_OWNER, filesAccountId()) + ); } -function filesAccountId(): string { +export function filesAccountId(): string { return ( jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ?? jmapClient.accountId ); } +// Gate on the ACCOUNT capability, not only the server-wide session +// capability. A server can advertise urn:ietf:params:jmap:filenode while a +// specific account has its jmap-file-node-* permissions revoked, in which case +// the capability is absent from that account's accountCapabilities and every +// FileNode action fails with an authorization error (#563). Non-personal +// (shared/group) accounts don't always advertise per-account, so treat those +// as capable. Mirrors the webmail's `supportsFiles`. +export function accountSupportsFiles( + account: JMAPAccountInfo | undefined, + sessionCapabilities: Record | undefined, +): boolean { + if (!sessionCapabilities || !(CAPABILITIES.FILES in sessionCapabilities)) return false; + if (!account) return false; + return account.accountCapabilities?.[CAPABILITIES.FILES] != null || !account.isPersonal; +} + +export function supportsFiles(): boolean { + const session = jmapClient.currentSession; + if (!session) return false; + return accountSupportsFiles(session.accounts?.[filesAccountId()], session.capabilities); +} + +/** Server-advertised upload ceiling in bytes (0 = unknown / unlimited). */ +export function getMaxSizeUpload(): number { + return jmapClient.getMaxSizeUpload(); +} + function fileUsing(): string[] { const using: string[] = [CAPABILITIES.CORE]; if (jmapClient.hasCapability(CAPABILITIES.FILES)) { @@ -68,7 +105,10 @@ export async function getAllFileNodes(): Promise { if (!result || result[0] === 'error') { throw new Error(result?.[1]?.description || 'FileNode list failed'); } - return (result[1].list ?? []) as FileNode[]; + return ((result[1].list ?? []) as FileNode[]).map((node) => ({ + ...node, + name: decodeFileNodeName(node.name), + })); } // Accounts (primary + shared/group) that can hold FileNodes: any non-primary @@ -107,6 +147,7 @@ export async function getAllFileNodesAcrossAccounts(): Promise { for (const node of (result[1].list ?? []) as FileNode[]) { all.push({ ...node, + name: decodeFileNodeName(node.name), id: isPrimary ? node.id : `${accountId}:${node.id}`, parentId: node.parentId == null ? null @@ -163,6 +204,12 @@ export async function renameFileNode(id: string, newName: string): Promise await updateFileNode(id, { name: newName }); } +// Re-parent a node. `null` moves it to the drive root (the property must be +// sent explicitly as null, omitting it would leave the node where it is). +export async function moveFileNode(id: string, parentId: string | null): Promise { + await updateFileNode(id, { parentId }); +} + export async function deleteFileNodes(ids: string[]): Promise { if (ids.length === 0) return; const accountId = filesAccountId(); @@ -191,37 +238,75 @@ export function getFileNodeDownloadUrl(node: FileNode): string { return getDownloadUrl(node.blobId, node.name, node.type, node.accountId); } -export async function uploadFileNode( - uri: string, +// Stalwart caps the stored MIME type; very long types fail the create. +function safeMimeType(type: string | undefined, fallback: string): string { + const t = type || fallback || 'application/octet-stream'; + return t.length > 30 ? 'application/octet-stream' : t; +} + +async function createFileNodeFromBlob( name: string, - mimeType: string, + blobId: string, + type: string, + size: number | undefined, parentId: string | null, ): Promise { const accountId = filesAccountId(); - const blob = await uploadBlob(uri, mimeType); - const type = blob.type || mimeType || 'application/octet-stream'; - const props: Record = { - name, - // Stalwart caps the stored MIME type; very long types fail the create. - type: type.length > 30 ? 'application/octet-stream' : type, - blobId: blob.blobId, - size: blob.size, - }; + const props: Record = { name, type, blobId }; + if (size != null) props.size = size; if (parentId !== null) props.parentId = parentId; const res = await jmapClient.request( [['FileNode/set', { accountId, create: { 'new-file': props } }, '0']], fileUsing(), ); - const result = res.methodResponses[0][1]; - const created = result.created?.['new-file']; + const result = res.methodResponses[0]; + if (!result || result[0] === 'error') { + throw new Error(result?.[1]?.description || 'FileNode/set create failed'); + } + const created = result[1].created?.['new-file']; if (!created) { - const err = result.notCreated?.['new-file']; + const err = result[1].notCreated?.['new-file']; throw new Error(err?.description || 'Upload failed'); } return { ...props, ...created } as FileNode; } +export async function uploadFileNode( + uri: string, + name: string, + mimeType: string, + parentId: string | null, + options: UploadBlobOptions = {}, +): Promise { + const blob = await uploadBlob(uri, mimeType, options); + return createFileNodeFromBlob( + name, + blob.blobId, + safeMimeType(blob.type, mimeType), + blob.size, + parentId, + ); +} + +// Copy a file by creating a new node that references the same blob — no +// bytes are re-uploaded (webmail `copyFileNode`). Folders have no blob and +// cannot be duplicated this way. +export async function copyFileNode( + node: FileNode, + parentId: string | null, + newName: string = node.name, +): Promise { + if (!node.blobId) throw new Error('Folders cannot be duplicated'); + return createFileNodeFromBlob( + newName, + node.blobId, + safeMimeType(node.type, 'application/octet-stream'), + node.size, + parentId, + ); +} + // ── Sharing (RFC 9670) ──────────────────────────────────── // Add, update, or remove a principal's rights on an owned FileNode (file or diff --git a/src/lib/__tests__/filenode-name.test.ts b/src/lib/__tests__/filenode-name.test.ts new file mode 100644 index 00000000..5bc359e4 --- /dev/null +++ b/src/lib/__tests__/filenode-name.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { decodeFileNodeName, getUniqueName } from '../filenode-name'; + +describe('decodeFileNodeName (#869)', () => { + it('decodes WebDAV-created percent-encoded names', () => { + expect(decodeFileNodeName('Spares%20Catalog')).toBe('Spares Catalog'); + expect(decodeFileNodeName('%D8%AA%D9%82%D8%B1%D9%8A%D8%B1.pdf')).toBe('تقرير.pdf'); + }); + + it('leaves names without a valid escape untouched', () => { + expect(decodeFileNodeName('100% done.txt')).toBe('100% done.txt'); + expect(decodeFileNodeName('plain.txt')).toBe('plain.txt'); + expect(decodeFileNodeName('50%zz')).toBe('50%zz'); + }); + + it('refuses decodes that would introduce a path separator or NUL', () => { + expect(decodeFileNodeName('a%2Fb.txt')).toBe('a%2Fb.txt'); + expect(decodeFileNodeName('a%00b')).toBe('a%00b'); + }); + + it('keeps the raw name when decoding throws on a malformed sequence', () => { + expect(decodeFileNodeName('%20%E0%A4%A')).toBe('%20%E0%A4%A'); + }); +}); + +describe('getUniqueName', () => { + it('returns the name unchanged when free', () => { + expect(getUniqueName('a.txt', new Set(['b.txt']))).toBe('a.txt'); + }); + + it('appends a counter before the extension', () => { + expect(getUniqueName('a.txt', new Set(['a.txt']))).toBe('a (1).txt'); + expect(getUniqueName('a.txt', new Set(['a.txt', 'a (1).txt']))).toBe('a (2).txt'); + }); + + it('handles names without an extension and dotfiles', () => { + expect(getUniqueName('README', new Set(['README']))).toBe('README (1)'); + expect(getUniqueName('.env', new Set(['.env']))).toBe('.env (1)'); + }); +}); diff --git a/src/lib/capabilities.ts b/src/lib/capabilities.ts index 9c322a68..541ef70e 100644 --- a/src/lib/capabilities.ts +++ b/src/lib/capabilities.ts @@ -1,5 +1,7 @@ import { CAPABILITIES } from '../api/types'; +import type { JMAPSession } from '../api/types'; import { useAuthStore } from '../stores/auth-store'; +import { accountSupportsFiles } from '../api/files'; // When the session is null (cold start, offline restore) we assume features // are available so they don't flicker off mid-restore. Once the live session @@ -18,8 +20,21 @@ export function hasContactsCapability(): boolean { return sessionHasCapability(CAPABILITIES.CONTACTS); } +// Files is gated on the ACCOUNT capability (or a non-personal account), not +// just the server-wide session capability: an account whose filenode +// permissions were revoked still sees the capability in the session but +// every FileNode call fails (#563). +function sessionSupportsFiles(session: JMAPSession | null): boolean { + if (!session) return true; + const filesAccountId = session.primaryAccounts?.[CAPABILITIES.FILES]; + const accountId = filesAccountId ?? useAuthStore.getState().activeAccountId ?? ''; + const account = session.accounts?.[accountId] + ?? (filesAccountId ? undefined : Object.values(session.accounts ?? {}).find((a) => a.isPersonal)); + return accountSupportsFiles(account, session.capabilities); +} + export function hasFilesCapability(): boolean { - return sessionHasCapability(CAPABILITIES.FILES); + return sessionSupportsFiles(useAuthStore.getState().session); } export function useHasCalendar(): boolean { @@ -31,7 +46,7 @@ export function useHasContacts(): boolean { } export function useHasFiles(): boolean { - return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true)); + return useAuthStore((s) => sessionSupportsFiles(s.session)); } // Settings tabs for Sieve filters and the vacation responder are gated on the diff --git a/src/lib/filenode-name.ts b/src/lib/filenode-name.ts new file mode 100644 index 00000000..2aab048f --- /dev/null +++ b/src/lib/filenode-name.ts @@ -0,0 +1,41 @@ +// Ported from the webmail's lib/jmap/filenode-name.ts (#869). +// +// Percent-decode a FileNode name that Stalwart stored in URI form. +// +// When a file or folder is created over WebDAV (rclone, WinSCP, Finder, ...), +// Stalwart keeps the raw, percent-encoded path segment as the FileNode name - +// "Spares Catalog" arrives as "Spares%20Catalog" and Arabic/other non-ASCII +// names as UTF-8 escapes. WebDAV clients never notice because Stalwart echoes +// that string back verbatim in and they decode it, but JMAP +// FileNode/get returns it as-is, so the Files tab showed "Spares%20Catalog". +// Decode it at the API boundary so the UI shows the human-readable name. +// +// Conservative on purpose: a name is only decoded when it contains at least +// one valid %XX escape and decodes cleanly; a literal "%" in a JMAP-created +// name ("100% done.txt") is left untouched. A decoded "/" would turn a name +// into a path, so such names are also left alone. +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; + +export function decodeFileNodeName(name: string): string { + if (!PERCENT_ESCAPE.test(name)) return name; + try { + const decoded = decodeURIComponent(name); + if (decoded.includes('/') || decoded.includes('\0')) return name; + return decoded; + } catch { + return name; + } +} + +// Append " (1)", " (2)", ... before the extension until the name is free in +// the target folder (webmail file-store getUniqueName). Used when uploading a +// file whose name already exists so two nodes never share one name. +export function getUniqueName(name: string, existingNames: Set): string { + if (!existingNames.has(name)) return name; + const dotIndex = name.lastIndexOf('.'); + const base = dotIndex > 0 ? name.substring(0, dotIndex) : name; + const ext = dotIndex > 0 ? name.substring(dotIndex) : ''; + let counter = 1; + while (existingNames.has(`${base} (${counter})${ext}`)) counter++; + return `${base} (${counter})${ext}`; +}