mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user