fix: share files on Stalwart versions before 0.16.6

Stalwart before 0.16.6 implements an older JMAP File Storage draft
whose rights are only `mayRead`, `mayWrite` and `mayShare`. Bulwark
always sent the newer `mayAddChildren` / `mayRename` / `mayDelete` /
`mayModifyContent`, so sharing a file or folder failed there with
`Invalid permission "mayAddChildren"`, and the rights such a server
reported did not match what the UI checks.

When the account's filenode capability lacks `forbiddenNameChars`
(added by the same draft update), shares are now written with
`mayWrite` set from the finer rights, and `mayWrite` read from the
server is spread over them.
This commit is contained in:
Linus Rath
2026-09-22 20:49:44 +02:00
parent 1ecddf3cbb
commit c3225b3fa8
2 changed files with 72 additions and 4 deletions
@@ -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 }],
+51 -4
View File
@@ -33,6 +33,47 @@ function withDecodedName<T extends Pick<FileNode, 'name'>>(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 <email>" 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<string[]> {
@@ -7306,7 +7347,7 @@ export class JMAPClient implements IJMAPClient {
*/
async listAllFileNodes(): Promise<FileNode[]> {
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<string, unknown> | 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());