fix: create folders and upload files on Stalwart 1.0 and tell its symlinks from folders

This commit is contained in:
Linus Rath
2026-10-29 01:53:51 +01:00
parent 24232bf2e9
commit 174c3fc8fa
3 changed files with 338 additions and 24 deletions
+81
View File
@@ -674,6 +674,87 @@ describe('fileNodeKind', () => {
}); });
}); });
describe('Stalwart 1.0.0 (File Storage draft 18)', () => {
// The filenode account capability of 1.0.0 lists `supportedNodeTypes`; 0.16.27's doesn't.
const CAP_100 = { maxFileNodeDepth: 64, forbiddenNameChars: '/<>:"\\|?*', supportedNodeTypes: ['Directory', 'File', 'SymLink'] };
const CAP_016 = { maxFileNodeDepth: null, forbiddenNameChars: '/<>:"\\|?*' };
function session(own: object, shared?: object) {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true, accountCapabilities: { [CAPABILITIES.FILES]: own } },
...(shared ? { 'acc-2': { name: 'Team', isPersonal: false, accountCapabilities: shared } } : {}),
},
});
}
const creates = () => mockRequest.mock.calls.map(([calls]) => Object.values(calls[0][1].create ?? {})[0]);
// Seen live: 1.0.0 answers a create without `@type` with "@type is required
// on creation." and one with `type` with "Invalid property.".
it('creates folders and files with @type and mediaType', async () => {
session(CAP_100);
mockUploadBlobOnce('blob-up', 'text/plain');
mockRequest
.mockResolvedValueOnce({ methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'd1' } } }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'f1', size: 3 } } }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'f2', size: 3 } } }, '0']] });
const dir = await createFolder('New', null);
const { uploaded } = await uploadFileBatch([{ uri: 'file:///b', name: 'b.txt', mimeType: 'text/plain' }], 'd1', []);
const copy = await copyFileNode(uploaded[0], null, 'b (1).txt');
expect(creates()).toEqual([
{ '@type': 'Directory', name: 'New' },
{ '@type': 'File', name: 'b.txt', mediaType: 'text/plain', blobId: 'blob-up', parentId: 'd1' },
{ '@type': 'File', name: 'b (1).txt', mediaType: 'text/plain', blobId: 'blob-up' },
]);
expect(dir).toMatchObject({ id: 'd1', name: 'New', nodeType: 'directory' });
expect(copy).toMatchObject({ id: 'f2', type: 'text/plain', nodeType: 'file' });
});
it('keeps the 0.16 shape on Stalwart 0.16, which refuses @type', async () => {
session(CAP_016);
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'd1' }, 'new-file': { id: 'f1' } } }, '0']] });
await createFolder('New', null);
await copyFileNode({ id: 'f0', name: 'a.txt', type: 'text/plain', blobId: 'b1' }, null);
expect(creates()).toEqual([
{ name: 'New' },
{ name: 'a.txt', type: 'text/plain', blobId: 'b1' },
]);
});
it('reads the kind from @type and the media type from mediaType', async () => {
session(CAP_100);
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', {
list: [
{ id: 'd1', name: 'Docs', parentId: null, '@type': 'Directory' },
{ id: 'l1', name: 'latest', parentId: 'd1', '@type': 'SymLink' },
{ id: 'f1', name: 'a.pdf', parentId: 'd1', '@type': 'File', mediaType: 'application/pdf', blobId: 'b1', size: 3 },
],
}, '0']],
});
const nodes = await getAllFileNodes();
expect(mockRequest.mock.calls[0][0][0][1].properties).toEqual(expect.arrayContaining(['@type', 'mediaType']));
const byId = Object.fromEntries(nodes.map((n) => [n.id, n]));
expect(fileNodeKind(byId.d1)).toBe('directory');
// Without @type the blob rule made this an empty folder.
expect(isSymlink(byId.l1)).toBe(true);
expect(isFolder(byId.l1)).toBe(false);
expect(byId.f1).toMatchObject({ type: 'application/pdf', nodeType: 'file' });
});
});
function mockUploadBlobOnce(blobId: string, type: string) {
(uploadBlob as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ blobId, type, size: 3 });
}
describe('"Shared with me" placeholders', () => { describe('"Shared with me" placeholders', () => {
const RIGHTS_NONE = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false }; const RIGHTS_NONE = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false };
const RIGHTS_READ = { ...RIGHTS_NONE, mayRead: true }; const RIGHTS_READ = { ...RIGHTS_NONE, mayRead: true };
@@ -0,0 +1,182 @@
/**
* Live check of the Files API against a real Stalwart server.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`
* and `BULWARK_LIVE_PASS` are set.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-phone-dark@example.org \
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-files.test.ts
*
* Everything it makes lives in one scratch folder that is deleted afterwards.
*
* Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
// The app uploads picked files from a device URI; here the "URI" names bytes
// held in memory, which go up through the same upload endpoint.
const pickedFiles = new Map<string, Uint8Array>();
vi.mock('../blob', async (importOriginal) => {
const actual = await importOriginal<typeof import('../blob')>();
return {
...actual,
uploadBlob: (uri: string, type: string) => {
const bytes = pickedFiles.get(uri);
if (!bytes) throw new Error(`no bytes for ${uri}`);
return actual.uploadBytes(bytes, type);
},
};
});
import { JMAPClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import type { FileNode } from '../types';
import {
copyFileNode,
createFolder,
deleteFileNodes,
fileNodeKind,
filesAccountId,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
listFolder,
moveFileNode,
renameFileNode,
uploadFileBatch,
} from '../files';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && PASS ? describe : describe.skip;
let client: JMAPClient;
async function connectSingleton(): Promise<void> {
const mod = await import('../jmap-client');
client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
}
function pick(name: string, text: string) {
const uri = `mem://${name}-${pickedFiles.size}`;
pickedFiles.set(uri, new TextEncoder().encode(text));
return { uri, name, mimeType: 'text/plain' };
}
async function nodes(): Promise<FileNode[]> {
return getAllFileNodesAcrossAccounts();
}
/** Stalwart 1.0 (File Storage draft 18) names the kind `@type`; it lists `supportedNodeTypes`. */
function draft18(): boolean {
const cap = client.getAccountCapability(CAPABILITIES.FILES, filesAccountId()) as Record<string, unknown> | undefined;
return !!cap && 'supportedNodeTypes' in cap;
}
live('live Stalwart files', () => {
let scratch = '';
beforeAll(async () => {
await connectSingleton();
scratch = (await createFolder(`live-files-${Date.now().toString(36)}`, null)).id;
}, 60_000);
afterAll(async () => {
if (!scratch) return;
await deleteFileNodes([scratch]).catch(() => undefined);
// Asked by id, so a failed listing can't pass for a clean account.
const res = await client.request([['FileNode/get', {
accountId: filesAccountId(), ids: [scratch], properties: ['id'],
}, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]);
expect(res.methodResponses[0][1].notFound).toEqual([scratch]);
}, 120_000);
it('creates a folder, lists it as a folder and lists what it holds', async () => {
const sub = await createFolder('Sub folder', scratch);
const all = await nodes();
const listed = all.find((n) => n.id === sub.id);
expect(listed?.name).toBe('Sub folder');
expect(listed?.parentId).toBe(scratch);
expect(fileNodeKind(listed!)).toBe('directory');
expect(listFolder(all, scratch).map((n) => n.id)).toContain(sub.id);
}, 60_000);
it('uploads files, lists them as files with their media type and downloads the bytes', async () => {
const { uploaded, failed } = await uploadFileBatch(
[pick('notes.txt', 'hello from the live test'), pick('con.txt', 'reserved stem')],
scratch,
[],
);
expect(failed).toEqual([]);
expect(uploaded).toHaveLength(2);
const all = await nodes();
const notes = all.find((n) => n.id === uploaded[0].id)!;
expect(notes.name).toBe('notes.txt');
expect(fileNodeKind(notes)).toBe('file');
expect(notes.type).toBe('text/plain');
expect(notes.size).toBe(24);
const res = await fetch(getFileNodeDownloadUrl(notes), { headers: { Authorization: client.authHeader } });
expect(res.status).toBe(200);
expect(await res.text()).toBe('hello from the live test');
}, 60_000);
it('renames, moves, copies and deletes', async () => {
const box = await createFolder('Box', scratch);
const { uploaded } = await uploadFileBatch([pick('a.txt', 'abc')], scratch, []);
const file = uploaded[0];
await renameFileNode(file.id, 'b.txt');
await moveFileNode(file.id, box.id);
let all = await nodes();
expect(all.find((n) => n.id === file.id)).toMatchObject({ name: 'b.txt', parentId: box.id });
// A folder moves too, and back.
const inner = await createFolder('Inner', scratch);
await moveFileNode(inner.id, box.id);
await renameFileNode(inner.id, 'Inner renamed');
all = await nodes();
expect(all.find((n) => n.id === inner.id)).toMatchObject({ name: 'Inner renamed', parentId: box.id });
const listed = all.find((n) => n.id === file.id)!;
const copy = await copyFileNode(listed, scratch, 'b (copy).txt');
all = await nodes();
const copied = all.find((n) => n.id === copy.id)!;
expect(copied).toMatchObject({ name: 'b (copy).txt', parentId: scratch, size: 3, type: 'text/plain' });
expect(fileNodeKind(copied)).toBe('file');
await deleteFileNodes([copy.id, box.id]);
all = await nodes();
// The folder's children went with it.
expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]);
}, 90_000);
it('moves a node to the drive root and back', async () => {
const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []);
const id = uploaded[0].id;
try {
await moveFileNode(id, null);
expect((await nodes()).find((n) => n.id === id)?.parentId ?? null).toBeNull();
} finally {
await moveFileNode(id, scratch);
}
expect((await nodes()).find((n) => n.id === id)?.parentId).toBe(scratch);
}, 60_000);
it('cannot create a symbolic link over JMAP on Stalwart 1.0', async () => {
if (!draft18()) return;
// Stalwart 1.0 lists SymLink among its node types but refuses every
// `target`, so the app offers no way to make one.
const res = await client.request([['FileNode/set', {
accountId: filesAccountId(),
create: { l: { '@type': 'SymLink', name: 'link', parentId: scratch, target: 'Sub folder' } },
}, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]);
const [, result] = res.methodResponses[0] as unknown as [string, { created?: Record<string, unknown>; notCreated?: Record<string, { properties?: string[] }> }];
expect(result.created?.l).toBeUndefined();
expect(result.notCreated?.l?.properties).toEqual(['target']);
}, 30_000);
});
+75 -24
View File
@@ -40,6 +40,37 @@ export function isSymlink(node: Pick<FileNode, 'nodeType' | 'blobId'>): boolean
return fileNodeKind(node) === 'symlink'; return fileNodeKind(node) === 'symlink';
} }
/**
* Stalwart 1.0.0 implements File Storage draft 18, which names the kind
* `@type` ("File", "Directory", "SymLink") and the media type `mediaType`.
* 0.16 calls them `nodeType` and `type`. Each server leaves the other's names
* out of FileNode/get and refuses them in a create ("@type is required on
* creation." on 1.0.0, "Invalid property." for `@type` on 0.16.27).
*/
const DRAFT_NODE_TYPES: Record<string, FileNodeType> = { File: 'file', Directory: 'directory', SymLink: 'symlink' };
type DraftFileNode = { '@type'?: unknown; mediaType?: unknown };
/** A node of either draft, with the kind in `nodeType` and the media type in `type`. */
export function fromDraftFileNode<T extends Partial<FileNode>>(node: T & DraftFileNode): T {
const kind = typeof node['@type'] === 'string' ? DRAFT_NODE_TYPES[node['@type']] : undefined;
const mediaType = typeof node.mediaType === 'string' ? node.mediaType : undefined;
if (!kind && mediaType === undefined) return node;
return {
...node,
...(kind && !node.nodeType ? { nodeType: kind } : {}),
...(mediaType !== undefined && node.type == null ? { type: mediaType } : {}),
};
}
/** The properties of a draft-18 FileNode/set create, from 0.16 ones (no blob: a folder). */
function toDraftFileNodeCreate(props: Record<string, unknown>): Record<string, unknown> {
const { type, ...rest } = props;
return rest.blobId == null
? { '@type': 'Directory', ...rest }
: { '@type': 'File', ...rest, mediaType: type };
}
// Stalwart omits shareWith/myRights from FileNode/get unless they are // Stalwart omits shareWith/myRights from FileNode/get unless they are
// requested explicitly, so the share sheet and indicators must name them here. // requested explicitly, so the share sheet and indicators must name them here.
// The modification timestamp is `modified` (RFC draft-ietf-jmap-filenode); // The modification timestamp is `modified` (RFC draft-ietf-jmap-filenode);
@@ -50,6 +81,8 @@ const FILE_NODE_PROPERTIES = [
// Tells 1.0's symlinks from folders (see fileNodeKind); servers before // Tells 1.0's symlinks from folders (see fileNodeKind); servers before
// 0.16.6 skip it. // 0.16.6 skip it.
'nodeType', 'nodeType',
// `nodeType` and `type` as Stalwart 1.0.0 names them (see fromDraftFileNode).
'@type', 'mediaType',
'shareWith', 'myRights', 'shareWith', 'myRights',
]; ];
@@ -162,7 +195,7 @@ async function fetchAllFileNodes(accountId: string): Promise<FileNode[]> {
if (!result || result[0] === 'error') { if (!result || result[0] === 'error') {
throw new Error(result?.[1]?.description || 'FileNode list failed'); throw new Error(result?.[1]?.description || 'FileNode list failed');
} }
const firstPage = (result[1].list ?? []) as FileNode[]; const firstPage = ((result[1].list ?? []) as FileNode[]).map(fromDraftFileNode);
const maxObjects = jmapClient.getMaxObjectsInGet(); const maxObjects = jmapClient.getMaxObjectsInGet();
if (firstPage.length < maxObjects) return firstPage; if (firstPage.length < maxObjects) return firstPage;
@@ -178,7 +211,7 @@ async function fetchAllFileNodes(accountId: string): Promise<FileNode[]> {
if (r[0] !== 'FileNode/get') { if (r[0] !== 'FileNode/get') {
throw new Error(r[1]?.description || 'FileNode/get failed'); throw new Error(r[1]?.description || 'FileNode/get failed');
} }
for (const node of (r[1].list ?? []) as FileNode[]) known.set(node.id, node); for (const node of (r[1].list ?? []) as FileNode[]) known.set(node.id, fromDraftFileNode(node));
} }
} }
}; };
@@ -325,6 +358,44 @@ export function listFolder(nodes: FileNode[], parentId: string | null): FileNode
return nodes.filter((n) => !n.discoverableOnly && (n.parentId ?? null) === parentId); return nodes.filter((n) => !n.discoverableOnly && (n.parentId ?? null) === parentId);
} }
/**
* Stalwart 1.0.0 (File Storage draft 18) wants `@type` and `mediaType` in a
* create (see toDraftFileNodeCreate). Of the drafts Stalwart has shipped,
* only that one lists `supportedNodeTypes`. A shared account may come
* without the capability; the user's own files account stands in, as
* Stalwart publishes the same one for every account.
*/
function usesDraftFileNodeCreate(accountId: string): boolean {
// Read the accounts' own capabilities: the session-wide one is empty on
// both versions.
const accounts = jmapClient.currentSession?.accounts;
const cap = accounts?.[accountId]?.accountCapabilities?.[CAPABILITIES.FILES]
?? accounts?.[filesAccountId()]?.accountCapabilities?.[CAPABILITIES.FILES];
return !!cap && typeof cap === 'object' && 'supportedNodeTypes' in cap;
}
/** One FileNode/set create in `accountId`, in the shape that server takes. */
async function createFileNodeIn(
accountId: string,
props: Record<string, unknown>,
key: string,
fallback: string,
): Promise<FileNode> {
const wire = usesDraftFileNodeCreate(accountId) ? toDraftFileNodeCreate(props) : props;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { [key]: wire } }, '0']],
fileUsing(),
);
const result = requireMethodResult(res, '0', 'FileNode/set');
const created = result.created?.[key];
if (!created) {
throw fileSetError(result.notCreated?.[key], fallback);
}
// The create response carries only server-set properties; keep the ones
// sent so callers can rely on name, parentId and type.
return fromDraftFileNode({ ...wire, ...created } as FileNode);
}
export async function createFolder( export async function createFolder(
name: string, name: string,
parentId: string | null, parentId: string | null,
@@ -332,17 +403,7 @@ export async function createFolder(
const accountId = filesAccountId(); const accountId = filesAccountId();
const props: Record<string, unknown> = { name: acceptedFileName(name, getFileNameRules()) }; const props: Record<string, unknown> = { name: acceptedFileName(name, getFileNameRules()) };
if (parentId !== null) props.parentId = parentId; if (parentId !== null) props.parentId = parentId;
return createFileNodeIn(accountId, props, 'new-dir', 'Create folder failed');
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
fileUsing(),
);
const result = requireMethodResult(res, '0', 'FileNode/set');
const created = result.created?.['new-dir'];
if (!created) {
throw fileSetError(result.notCreated?.['new-dir'], 'Create folder failed');
}
return { ...props, ...created } as FileNode;
} }
export async function updateFileNode( export async function updateFileNode(
@@ -421,17 +482,7 @@ async function createFileNodeFromBlob(
blobId, blobId,
}; };
if (parentId !== null) props.parentId = parentId; if (parentId !== null) props.parentId = parentId;
return createFileNodeIn(accountId, props, 'new-file', 'Upload failed');
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
fileUsing(),
);
const result = requireMethodResult(res, '0', 'FileNode/set');
const created = result.created?.['new-file'];
if (!created) {
throw fileSetError(result.notCreated?.['new-file'], 'Upload failed');
}
return { ...props, ...created } as FileNode;
} }
export async function uploadFileNode( export async function uploadFileNode(