feat: JMAP file/folder sharing in Files app #408
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import type { FileNodeRights } from '../jmap/types';
|
||||
|
||||
function createClient(): JMAPClient {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
|
||||
Object.assign(client, {
|
||||
apiUrl: 'https://jmap.example.com/api',
|
||||
accountId: 'account-1',
|
||||
capabilities: { 'urn:ietf:params:jmap:filenode': {}, 'urn:ietf:params:jmap:principals': {} },
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function mockFetch(response: object, ok = true, status = 200) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
text: () => Promise.resolve(JSON.stringify(response)),
|
||||
json: () => Promise.resolve(response),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
const READ: FileNodeRights = {
|
||||
mayRead: true, mayAddChildren: false, mayRename: false,
|
||||
mayDelete: false, mayModifyContent: false, mayShare: false,
|
||||
};
|
||||
|
||||
function lastRequestBody(spy: ReturnType<typeof vi.spyOn>): { using: string[]; methodCalls: unknown[][] } {
|
||||
const call = spy.mock.calls[spy.mock.calls.length - 1];
|
||||
return JSON.parse((call[1] as RequestInit).body as string);
|
||||
}
|
||||
|
||||
describe('JMAPClient.setFileNodeShare', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sends a FileNode/set shareWith patch and resolves on success', async () => {
|
||||
const spy = mockFetch({
|
||||
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
|
||||
});
|
||||
const client = createClient();
|
||||
|
||||
await client.setFileNodeShare('node-1', 'principal-9', READ);
|
||||
|
||||
const body = lastRequestBody(spy);
|
||||
expect(body.using).toContain('urn:ietf:params:jmap:filenode');
|
||||
expect(body.using).toContain('urn:ietf:params:jmap:principals:owner');
|
||||
const [method, args] = body.methodCalls[0] as [string, Record<string, unknown>];
|
||||
expect(method).toBe('FileNode/set');
|
||||
expect(args.accountId).toBe('account-1');
|
||||
expect(args.update).toEqual({
|
||||
'node-1': { 'shareWith/principal-9': READ },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends null to revoke a principal\'s access', async () => {
|
||||
const spy = mockFetch({
|
||||
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
|
||||
});
|
||||
const client = createClient();
|
||||
|
||||
await client.setFileNodeShare('node-1', 'principal-9', null);
|
||||
|
||||
const body = lastRequestBody(spy);
|
||||
const [, args] = body.methodCalls[0] as [string, Record<string, unknown>];
|
||||
expect(args.update).toEqual({ 'node-1': { 'shareWith/principal-9': null } });
|
||||
});
|
||||
|
||||
it('throws with the server description when the update is rejected', async () => {
|
||||
mockFetch({
|
||||
methodResponses: [['FileNode/set', {
|
||||
notUpdated: { 'node-1': { type: 'forbidden', description: 'Not allowed' } },
|
||||
}, '0']],
|
||||
});
|
||||
const client = createClient();
|
||||
|
||||
await expect(client.setFileNodeShare('node-1', 'principal-9', READ))
|
||||
.rejects.toThrow('Not allowed');
|
||||
});
|
||||
|
||||
it('throws when the server does not confirm the update', async () => {
|
||||
mockFetch({
|
||||
methodResponses: [['FileNode/set', { updated: {} }, '0']],
|
||||
});
|
||||
const client = createClient();
|
||||
|
||||
await expect(client.setFileNodeShare('node-1', 'principal-9', READ))
|
||||
.rejects.toThrow('did not confirm');
|
||||
});
|
||||
});
|
||||
@@ -912,6 +912,12 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return [...this.data.fileNodes];
|
||||
}
|
||||
|
||||
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||||
return [...this.data.fileNodes];
|
||||
}
|
||||
|
||||
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||
if (ids === null) return [...this.data.fileNodes];
|
||||
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -292,6 +292,7 @@ export interface IJMAPClient {
|
||||
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
@@ -308,6 +309,7 @@ export interface IJMAPClient {
|
||||
probeFileNodeSupport(): Promise<boolean>;
|
||||
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||
listAllFileNodes(): Promise<FileNode[]>;
|
||||
listAllFileNodesAcrossAccounts(): Promise<FileNode[]>;
|
||||
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||
|
||||
+102
-2
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -5073,10 +5073,37 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
|
||||
using.push("urn:ietf:params:jmap:filenode");
|
||||
}
|
||||
// Required for shareWith/myRights on FileNode and for cross-account
|
||||
// (shared-with-me) FileNode/get, mirroring calendarUsing().
|
||||
if (this.supportsPrincipals()) {
|
||||
using.push("urn:ietf:params:jmap:principals:owner");
|
||||
}
|
||||
return using;
|
||||
}
|
||||
|
||||
private static FILE_NODE_PROPERTIES = ["id", "parentId", "name", "type", "blobId", "size", "created", "updated"];
|
||||
private static FILE_NODE_PROPERTIES = [
|
||||
"id", "parentId", "name", "type", "blobId", "size", "created", "updated",
|
||||
// Stalwart omits shareWith/myRights from FileNode/get unless requested
|
||||
// explicitly, so the share dialog and indicators can't see existing
|
||||
// shares without naming them here (same as CALENDAR_PROPERTIES).
|
||||
"shareWith", "myRights",
|
||||
];
|
||||
|
||||
// Accounts (primary + shared/group) that can hold FileNodes. Mirrors
|
||||
// getCalendarCapableAccountIds(): includes any non-primary account that
|
||||
// advertises the filenode capability or is a non-personal (shared/group)
|
||||
// account, since Stalwart doesn't always advertise capabilities on those.
|
||||
private getFilesCapableAccountIds(): string[] {
|
||||
const primaryId = this.getFilesAccountId();
|
||||
const accountIds: string[] = [];
|
||||
for (const [id, account] of Object.entries(this.accounts)) {
|
||||
if (id === primaryId) continue;
|
||||
if (account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal) {
|
||||
accountIds.push(id);
|
||||
}
|
||||
}
|
||||
return [primaryId, ...accountIds];
|
||||
}
|
||||
|
||||
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
|
||||
const accountId = this.getFilesAccountId();
|
||||
@@ -5143,6 +5170,79 @@ export class JMAPClient implements IJMAPClient {
|
||||
return (getResult[1].list || []) as FileNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every FileNode the logged-in user can see across all connected and
|
||||
* shared accounts. Nodes owned by another principal (shared with the user)
|
||||
* are tagged with `isShared: true` and the owning `accountId`/`accountName`,
|
||||
* and their ids are namespaced `accountId:nodeId` so they don't collide with
|
||||
* the primary account's ids. Mirrors getAllCalendars().
|
||||
*/
|
||||
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||||
const primaryId = this.getFilesAccountId();
|
||||
const accountIds = this.getFilesCapableAccountIds();
|
||||
const all: FileNode[] = [];
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
const account = this.accounts[accountId];
|
||||
try {
|
||||
const response = await this.request(
|
||||
[["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get");
|
||||
if (!getResult || getResult[0] === "error") continue;
|
||||
const nodes = (getResult[1].list || []) as FileNode[];
|
||||
for (const node of nodes) {
|
||||
all.push({
|
||||
...node,
|
||||
id: isPrimary ? node.id : `${accountId}:${node.id}`,
|
||||
parentId: node.parentId == null
|
||||
? null
|
||||
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
|
||||
accountId,
|
||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||
isShared: !isPrimary,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Files] Failed to fetch FileNodes for account ${accountId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on a FileNode (file or folder).
|
||||
* Pass `rights: null` to revoke access. Mirrors setCalendarShare /
|
||||
* setAddressBookShare; Stalwart applies it via a `shareWith/{principalId}`
|
||||
* patch on FileNode/set.
|
||||
*/
|
||||
async setFileNodeShare(
|
||||
fileNodeId: string,
|
||||
principalId: string,
|
||||
rights: FileNodeRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.getFilesAccountId();
|
||||
const response = await this.request([
|
||||
["FileNode/set", {
|
||||
accountId,
|
||||
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
], this.fileUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[fileNodeId]) {
|
||||
const err = result.notUpdated[fileNodeId];
|
||||
throw new Error(err.description || "Failed to update file share");
|
||||
}
|
||||
if (!result?.updated || !(fileNodeId in result.updated)) {
|
||||
throw new Error("Server did not confirm the share update");
|
||||
}
|
||||
}
|
||||
|
||||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||
const accountId = this.getFilesAccountId();
|
||||
|
||||
|
||||
@@ -792,6 +792,32 @@ export interface FileNode {
|
||||
size: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||
// filenode capability and the properties are explicitly requested. A node is
|
||||
// shared-out when `shareWith` has entries; `myRights` describes what the
|
||||
// viewer may do (always full rights on owned nodes).
|
||||
myRights?: FileNodeRights;
|
||||
shareWith?: Record<string, FileNodeRights> | null;
|
||||
// True when this node was fetched from another principal's account that was
|
||||
// shared with the logged-in user (mirrors Calendar.isShared / AddressBook.isShared).
|
||||
isShared?: boolean;
|
||||
// Owning account's JMAP id and display name, set when aggregating nodes
|
||||
// across connected/shared accounts so mutations route to the right account.
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
// Local account-store id (per JMAP connection) in multi-account contexts.
|
||||
// See Calendar.localAccountId.
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
|
||||
export interface FileNodeRights {
|
||||
mayRead: boolean;
|
||||
mayAddChildren: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
mayModifyContent: boolean;
|
||||
mayShare: boolean;
|
||||
}
|
||||
|
||||
export interface FileNodeFilter {
|
||||
|
||||
Reference in New Issue
Block a user