diff --git a/lib/__tests__/jmap-filenode-writes.test.ts b/lib/__tests__/jmap-filenode-writes.test.ts index ebcf4aaee..8cf8d3474 100644 --- a/lib/__tests__/jmap-filenode-writes.test.ts +++ b/lib/__tests__/jmap-filenode-writes.test.ts @@ -89,6 +89,27 @@ describe('FileNode writes', () => { expect(calls.some(([m]) => m === 'Blob/copy')).toBe(false); }); + it('shares with the mayWrite rights of servers before 0.16.6', async () => { + const { client, calls } = makeClient({}); + await client.setFileNodeShare('n1', 'p1', { + mayRead: true, mayAddChildren: true, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false, + }).catch(() => {}); + expect(sets(calls)[0]).toMatchObject({ + accountId: 'me', + update: { n1: { 'shareWith/p1': { mayRead: true, mayWrite: true, mayShare: false } } }, + }); + }); + + it('reads old mayWrite rights as the finer rights', async () => { + const { client } = makeClient({ + me: [{ id: 'd1', name: 'Docs', parentId: null, blobId: null, myRights: { mayRead: true, mayWrite: true, mayShare: false } }], + }); + const [node] = await client.listAllFileNodes(); + expect(node.myRights).toEqual({ + mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false, + }); + }); + it('copies file content across accounts with Blob/copy', async () => { const { client, calls } = makeClient({ grp: [{ id: 'f1', name: 'shared.pdf', parentId: null, blobId: 'b1', type: 'application/pdf', size: 9 }], diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 828d75f14..3148b459d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -33,6 +33,47 @@ function withDecodedName>(node: T): T { return name === node.name ? node : { ...node, name }; } +type LegacyFileNodeRights = { mayRead?: boolean; mayWrite?: boolean; mayShare?: boolean }; + +/** + * Stalwart before 0.16.6 implements an older JMAP File Storage draft whose + * rights are just mayRead / mayWrite / mayShare. Spread mayWrite over the + * finer rights the UI checks. + */ +function fromLegacyRights(rights: FileNodeRights | LegacyFileNodeRights | undefined): FileNodeRights | undefined { + if (!rights || !('mayWrite' in rights)) return rights as FileNodeRights | undefined; + const write = !!rights.mayWrite; + return { + mayRead: !!rights.mayRead, + mayAddChildren: write, + mayRename: write, + mayDelete: write, + mayModifyContent: write, + mayShare: !!rights.mayShare, + }; +} + +function toLegacyRights(rights: FileNodeRights): LegacyFileNodeRights { + return { + mayRead: rights.mayRead, + mayWrite: rights.mayAddChildren || rights.mayRename || rights.mayDelete || rights.mayModifyContent, + mayShare: rights.mayShare, + }; +} + +/** A FileNode as the server sent it, normalized for the UI. */ +function fromWireFileNode(node: FileNode): FileNode { + const decoded = withDecodedName(node); + if (!decoded.myRights && !decoded.shareWith) return decoded; + return { + ...decoded, + myRights: fromLegacyRights(decoded.myRights), + shareWith: decoded.shareWith + ? Object.fromEntries(Object.entries(decoded.shareWith).map(([p, r]) => [p, fromLegacyRights(r) as FileNodeRights])) + : decoded.shareWith, + }; +} + /** * Parse a recipient string that may be "Name " or bare "email" into * { name?, email }. The display name is unquoted and stripped of any address @@ -7267,7 +7308,7 @@ export class JMAPClient implements IJMAPClient { if (!result || result[0] === "error") { throw new Error(result?.[1]?.description || "FileNode/get failed"); } - return ((result[1].list || []) as FileNode[]).map(withDecodedName); + return ((result[1].list || []) as FileNode[]).map(fromWireFileNode); } async queryFileNodes(filter: FileNodeFilter, sort?: { property: string; isAscending: boolean }[]): Promise { @@ -7306,7 +7347,7 @@ export class JMAPClient implements IJMAPClient { */ async listAllFileNodes(): Promise { const nodes = await this.fetchAllFileNodes(this.getFilesAccountId()); - return nodes.map(withDecodedName); + return nodes.map(fromWireFileNode); } /** @@ -7416,7 +7457,7 @@ export class JMAPClient implements IJMAPClient { const nodes = await this.fetchAllFileNodes(accountId); for (const node of nodes) { all.push({ - ...withDecodedName(node), + ...fromWireFileNode(node), id: isPrimary ? node.id : `${accountId}:${node.id}`, parentId: node.parentId == null ? null @@ -7451,10 +7492,16 @@ export class JMAPClient implements IJMAPClient { : this.resolveFileNodeId(fileNodeId); const accountId = node.accountId; const rawId = node.id as string; + // `forbiddenNameChars` arrived with the draft (Stalwart 0.16.6) that split + // mayWrite into the finer rights; older servers reject those names. + const fileCapability = this.session?.accounts?.[accountId]?.accountCapabilities?.["urn:ietf:params:jmap:filenode"] as + Record | undefined; + const legacyRights = !!fileCapability && !("forbiddenNameChars" in fileCapability); + const wireRights = rights && legacyRights ? toLegacyRights(rights) : rights; const response = await this.request([ ["FileNode/set", { accountId, - update: { [rawId]: { [`shareWith/${principalId}`]: rights } }, + update: { [rawId]: { [`shareWith/${principalId}`]: wireRights } }, }, "0"], ], this.fileUsing());