feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563)

This commit is contained in:
Linus Rath
2026-08-29 17:59:44 +02:00
parent 1cef53af5a
commit a0ce9258cd
7 changed files with 533 additions and 84 deletions
+78 -1
View File
@@ -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<string, string>; 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();
});
});
+108 -1
View File
@@ -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<typeof vi.fn>;
@@ -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<typeof vi.fn>;
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);
});
});
+143 -59
View File
@@ -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<LegacyFileSystemModule> {
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<string, unknown>,
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<UploadResult> {
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<string, unknown>;
try {
raw = JSON.parse(result.body) as Record<string, unknown>;
} 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<UploadResult> {
// 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<string, unknown>;
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<UploadResult> {
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<string, unknown>;
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(
+106 -21
View File
@@ -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<FileNode, 'blobId'>): 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<string, unknown> | 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<FileNode[]> {
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<FileNode[]> {
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<void>
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<void> {
await updateFileNode(id, { parentId });
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
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<FileNode> {
const accountId = filesAccountId();
const blob = await uploadBlob(uri, mimeType);
const type = blob.type || mimeType || 'application/octet-stream';
const props: Record<string, unknown> = {
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<string, unknown> = { 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<FileNode> {
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<FileNode> {
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
+40
View File
@@ -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)');
});
});
+17 -2
View File
@@ -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
+41
View File
@@ -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 <D:href> 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>): 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}`;
}