mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: reach directly shared files and keep others' files on a failed copy
Fixes from the review of the Files work (1cf9944b..a9a25b94).
- A file shared on its own, or one below a folder its owner took back,
could not be reached. Once Stalwart 1.0's placeholder folders were
left out it became a root of "Shared with me", which listed folders
only, and search opened it on the drive root. 0.16 lists such files
as roots too. Each owner's files shared on their own are now listed
on one page under "Shared with me" ("Files from <name>"), where they
open, preview and download like any other file and nothing can be
added; two of the same name get a number. Search opens a shared hit
where it is listed, and links into a shared drive name the node by id
(`/files?shared=<owner>:<id>`), so a shared folder or page also
survives a reload. A Files page that is already open takes the link
from search directly, since Next keeps it mounted when only the query
changes.
- A copy that fails part-way destroys only the nodes it made, the
deepest first and without `onDestroyRemoveChildren`, one call per
level because 0.16 refuses a folder destroyed in the same call as
its children. It used to destroy the new folder with everything
below it, so whatever someone put there during a long copy was
deleted for good. A folder holding such nodes now stays, and the
error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
without Modify, so every save of such a document failed with a 500.
1.0, told apart by its `maxFileNodeDepth`, still asks for
`mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
the 1.0 pre-releases a failed update also drops the cached copy, and
the next restart or reload of the Applications downloads it again,
not only a later `UpdateApps`. The static README says a restart
applies the CORS setting on the current 1.0 pre-releases, where
`ReloadSettings` fails with the stock spam-filter rules.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
setPendingDeepLink,
|
||||
deliverDeepLink,
|
||||
consumePendingDeepLink,
|
||||
consumePendingDeepLinkEntry,
|
||||
subscribePendingDeepLink,
|
||||
@@ -82,4 +83,16 @@ describe('deep-link handoff', () => {
|
||||
expect(consumePendingDeepLinkEntry('files')).toBeNull();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('delivers only to a mounted surface and parks nothing when none listens', () => {
|
||||
expect(deliverDeepLink('files', [], '?shared=owner1%3An5')).toBe(false);
|
||||
expect(consumePendingDeepLinkEntry('files')).toBeNull();
|
||||
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribePendingDeepLink('files', listener);
|
||||
expect(deliverDeepLink('files', [], '?shared=owner1%3An5')).toBe(true);
|
||||
expect(listener).toHaveBeenCalledWith([], '?shared=owner1%3An5');
|
||||
expect(consumePendingDeepLinkEntry('files')).toBeNull();
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildContactsPath,
|
||||
parseContactsPath,
|
||||
buildFilesPath,
|
||||
buildSharedFilesPath,
|
||||
parseFilesPath,
|
||||
buildSettingsPath,
|
||||
parseSettingsPath,
|
||||
@@ -272,6 +273,17 @@ describe('files paths', () => {
|
||||
expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf')))
|
||||
.toEqual({ path: '/Documents', preview: 'report.pdf' });
|
||||
});
|
||||
|
||||
it('names a node of a shared drive by id, since it has no path under the user\'s root', () => {
|
||||
const link = buildSharedFilesPath('owner1:n5', 'Q3 plan.pdf');
|
||||
expect(link).toBe('/files?shared=owner1%3An5&preview=Q3%20plan.pdf');
|
||||
const [, query] = link.split('?');
|
||||
expect(parseFilesPath([], new URLSearchParams(query)))
|
||||
.toEqual({ path: '/', preview: 'Q3 plan.pdf', shared: 'owner1:n5' });
|
||||
// An owner's page of files shared on their own.
|
||||
expect(parseFilesPath([], new URLSearchParams(buildSharedFilesPath('owner1:*').split('?')[1])))
|
||||
.toEqual({ path: '/', preview: undefined, shared: 'owner1:*' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings paths', () => {
|
||||
|
||||
@@ -463,5 +463,22 @@ describe('dev-jmap mock server', () => {
|
||||
expect(created.uploaded.size).toBe(5);
|
||||
expect(created.copied.size).toBe(148480);
|
||||
});
|
||||
|
||||
it('keeps a folder that still holds something unless onDestroyRemoveChildren is set, like a real server', async () => {
|
||||
const fileNodeSet = async (args: Record<string, unknown>) => {
|
||||
const res = await POST(makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||
body: JSON.stringify({ methodCalls: [['FileNode/set', { accountId: 'dev-account-001', ...args }, '0']] }),
|
||||
}), { params: Promise.resolve({ path: ['api'] }) });
|
||||
return (await res.json()).methodResponses[0][1];
|
||||
};
|
||||
const folder = (await fileNodeSet({ create: { f: { name: 'Rollback test' } } })).created.f.id;
|
||||
const inside = (await fileNodeSet({ create: { x: { name: 'x.pdf', type: 'application/pdf', blobId: 'demo-blob-file-2', parentId: folder } } })).created.x.id;
|
||||
|
||||
expect((await fileNodeSet({ destroy: [folder] })).notDestroyed[folder].type).toBe('nodeHasChildren');
|
||||
expect((await fileNodeSet({ destroy: [inside] })).destroyed).toEqual([inside]);
|
||||
expect((await fileNodeSet({ destroy: [folder] })).destroyed).toEqual([folder]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fileErrorKind, folderDepthError } from '../file-errors';
|
||||
import { fileErrorKind, folderDepthError, isPartialCopy, partialCopyError } from '../file-errors';
|
||||
import { jmapError } from '../jmap/jmap-error';
|
||||
|
||||
// SetErrors as Stalwart words them (1.0: jmap/src/file/*.rs; 0.16.23:
|
||||
@@ -31,6 +31,17 @@ describe('fileErrorKind', () => {
|
||||
expect(fileErrorKind(setError('forbidden', 'You are not allowed to modify this file node.'))).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('keeps why a copy stopped when part of it was left in place', () => {
|
||||
const cause = setError('overQuota', 'There are too many files, please delete some before adding a new one.');
|
||||
const partial = partialCopyError(cause);
|
||||
expect(isPartialCopy(partial)).toBe(true);
|
||||
expect(fileErrorKind(partial)).toBe('overQuota');
|
||||
expect(partial.message).toBe(cause.message);
|
||||
expect(fileErrorKind(partialCopyError(folderDepthError()))).toBe('tooDeep');
|
||||
expect(isPartialCopy(partialCopyError(new TypeError('Failed to fetch')))).toBe(true);
|
||||
expect(isPartialCopy(cause)).toBe(false);
|
||||
});
|
||||
|
||||
it('has nothing to add for other errors', () => {
|
||||
// The folder is gone, or a folder was moved into itself.
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Parent ID does not exist or is not a folder.', ['parentId']))).toBeNull();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import type { FileNodeListings } from '../jmap/client-interface';
|
||||
import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error';
|
||||
import { fileErrorKind } from '../file-errors';
|
||||
import { fileErrorKind, isPartialCopy } from '../file-errors';
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
|
||||
@@ -14,6 +15,8 @@ interface FakeOptions {
|
||||
capability?: Record<string, unknown>;
|
||||
/** Null: "grp" lists no filenode capability, as a shared account may. */
|
||||
sharedCapability?: Record<string, unknown> | null;
|
||||
/** Someone else puts a node into the created node of this name right after it is made. */
|
||||
intrudeInto?: string;
|
||||
}
|
||||
|
||||
// The rules Stalwart 1.0 publishes (0.16.23: the same with maxFileNodeDepth null).
|
||||
@@ -45,6 +48,9 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
|
||||
.mockReturnValue(['me', 'grp']);
|
||||
const calls: Call[] = [];
|
||||
let seq = 0;
|
||||
// Parent of every node the fake has created or been told about, to answer
|
||||
// destroys the way Stalwart does.
|
||||
const parents = new Map<string, string | null>();
|
||||
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
|
||||
.mockImplementation(async (reqCalls: Call[]) => {
|
||||
const [method, args, id] = reqCalls[0];
|
||||
@@ -57,9 +63,13 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
|
||||
return { methodResponses: [['Blob/copy', { copied: Object.fromEntries(blobIds.map(b => [b, `${b}-copy`])) }, id]] };
|
||||
}
|
||||
if (method === 'FileNode/set' && args.create) {
|
||||
const refusal = options.refuseCreate?.((args.create as { n0: Record<string, unknown> }).n0);
|
||||
const props = (args.create as { n0: Record<string, unknown> }).n0;
|
||||
const refusal = options.refuseCreate?.(props);
|
||||
if (refusal) return { methodResponses: [['FileNode/set', { notCreated: { n0: refusal } }, id]] };
|
||||
return { methodResponses: [['FileNode/set', { created: { n0: { id: `new${++seq}` } } }, id]] };
|
||||
const created = `new${++seq}`;
|
||||
parents.set(created, (props.parentId as string | undefined) ?? null);
|
||||
if (props.name === options.intrudeInto) parents.set('theirs', created);
|
||||
return { methodResponses: [['FileNode/set', { created: { n0: { id: created } } }, id]] };
|
||||
}
|
||||
if (method === 'FileNode/set' && args.update) {
|
||||
const ids = Object.keys(args.update as object);
|
||||
@@ -71,15 +81,26 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
|
||||
}
|
||||
if (method === 'FileNode/set' && args.destroy) {
|
||||
const ids = args.destroy as string[];
|
||||
const refused = ids.filter(k => options.refuse?.(k));
|
||||
return { methodResponses: [['FileNode/set', {
|
||||
destroyed: ids.filter(k => !refused.includes(k)),
|
||||
notDestroyed: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])),
|
||||
}, id]] };
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Record<string, unknown> = {};
|
||||
// Without onDestroyRemoveChildren, a folder that still holds anything
|
||||
// is refused. 0.16 checks against the tree as it was before the call,
|
||||
// so children destroyed in the same call still count (willDestroy).
|
||||
const childrenBefore = new Map(ids.map(k => [k, [...parents].filter(([, p]) => p === k).map(([c]) => c)]));
|
||||
for (const k of ids) {
|
||||
const refusal = options.refuse?.(k);
|
||||
const children = childrenBefore.get(k) ?? [];
|
||||
if (refusal) notDestroyed[k] = refusal;
|
||||
else if (args.onDestroyRemoveChildren || children.length === 0) destroyed.push(k);
|
||||
else if (children.some(c => ids.includes(c))) notDestroyed[k] = { type: 'willDestroy', description: 'File node or one of its children is already marked for deletion.' };
|
||||
else notDestroyed[k] = { type: 'nodeHasChildren', description: 'Cannot delete non-empty folder.' };
|
||||
}
|
||||
for (const k of destroyed) parents.delete(k);
|
||||
return { methodResponses: [['FileNode/set', { destroyed, notDestroyed }, id]] };
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
return { client, calls };
|
||||
return { client, calls, parents };
|
||||
}
|
||||
|
||||
const sets = (calls: Call[]) => calls.filter(([m]) => m === 'FileNode/set').map(([, a]) => a);
|
||||
@@ -233,6 +254,8 @@ describe('FileNode writes', () => {
|
||||
{ id: 'f2', name: 'b.txt', parentId: 'd1', blobId: 'b2', type: 'text/plain', size: 4 },
|
||||
];
|
||||
|
||||
const destroys = (calls: Call[]) => sets(calls).filter(a => a.destroy);
|
||||
|
||||
it('removes the half-made copy and reports why when a create fails part-way on Stalwart 1.0', async () => {
|
||||
const { client, calls } = makeClient({ me: docs }, {
|
||||
// 1.0 once an administrator has set maxFiles.
|
||||
@@ -241,8 +264,15 @@ describe('FileNode writes', () => {
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await expect(client.copyFileNode('d1', 'Docs', 'grp:d9')).rejects.toThrow('There are too many files');
|
||||
expect(sets(calls).at(-1)).toEqual({ accountId: 'grp', destroy: ['new1'], onDestroyRemoveChildren: true });
|
||||
const refused = await client.copyFileNode('d1', 'Docs', 'grp:d9').catch((error: unknown) => error);
|
||||
expect(refused).toBeInstanceOf(Error);
|
||||
expect((refused as Error).message).toContain('There are too many files');
|
||||
expect(isPartialCopy(refused)).toBe(false);
|
||||
// Only what the copy made, the file before its folder.
|
||||
expect(destroys(calls)).toEqual([
|
||||
{ accountId: 'grp', destroy: ['new2'] },
|
||||
{ accountId: 'grp', destroy: ['new1'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes the half-made copy when the server refuses a blob on 0.16', async () => {
|
||||
@@ -253,7 +283,50 @@ describe('FileNode writes', () => {
|
||||
});
|
||||
|
||||
await expect(client.copyFileNode('grp:d1', 'Docs', null)).rejects.toThrow('You do not have access to blobId b2.');
|
||||
expect(sets(calls).at(-1)).toEqual({ accountId: 'me', destroy: ['new1'], onDestroyRemoveChildren: true });
|
||||
expect(destroys(calls)).toEqual([
|
||||
{ accountId: 'me', destroy: ['new2'] },
|
||||
{ accountId: 'me', destroy: ['new1'] },
|
||||
]);
|
||||
});
|
||||
|
||||
// Docs > a.txt, Sub > b.txt, c.txt: c.txt is refused after the rest was made.
|
||||
const nested = [
|
||||
{ id: 'd1', name: 'Docs', parentId: null, blobId: null },
|
||||
{ id: 'f1', name: 'a.txt', parentId: 'd1', blobId: 'b1', type: 'text/plain', size: 3 },
|
||||
{ id: 'd2', name: 'Sub', parentId: 'd1', blobId: null },
|
||||
{ id: 'f2', name: 'b.txt', parentId: 'd2', blobId: 'b2', type: 'text/plain', size: 4 },
|
||||
{ id: 'f3', name: 'c.txt', parentId: 'd1', blobId: 'b3', type: 'text/plain', size: 5 },
|
||||
];
|
||||
const quotaOnC = (props: Record<string, unknown>) => props.name === 'c.txt'
|
||||
? { type: 'overQuota', description: 'There are too many files, please delete some before adding a new one.' }
|
||||
: undefined;
|
||||
|
||||
it('takes a copy back one level per call, deepest first, without onDestroyRemoveChildren (0.16 and 1.0)', async () => {
|
||||
// The fake answers like 0.16: a folder destroyed in the same call as its
|
||||
// children is refused (willDestroy). 1.0 takes either.
|
||||
const { client, calls, parents } = makeClient({ me: nested }, { refuseCreate: quotaOnC });
|
||||
|
||||
await expect(client.copyFileNode('d1', 'Docs', 'grp:d9')).rejects.toThrow('There are too many files');
|
||||
expect(destroys(calls)).toEqual([
|
||||
{ accountId: 'grp', destroy: ['new4'] },
|
||||
{ accountId: 'grp', destroy: ['new2', 'new3'] },
|
||||
{ accountId: 'grp', destroy: ['new1'] },
|
||||
]);
|
||||
expect([...parents.keys()]).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves a folder of the copy that someone added to meanwhile, and says the copy is partly there (0.16 and 1.0)', async () => {
|
||||
const { client, calls, parents } = makeClient({ me: nested }, { refuseCreate: quotaOnC, intrudeInto: 'Sub' });
|
||||
|
||||
const refused = await client.copyFileNode('d1', 'Docs', 'grp:d9').catch((error: unknown) => error);
|
||||
expect(isPartialCopy(refused)).toBe(true);
|
||||
// Still says why the copy stopped.
|
||||
expect(fileErrorKind(refused)).toBe('overQuota');
|
||||
expect((refused as Error).message).toContain('There are too many files');
|
||||
// Their file stays, and so do Sub (new3) and Docs (new1) above it.
|
||||
expect(destroys(calls).every(a => !('onDestroyRemoveChildren' in a))).toBe(true);
|
||||
expect(destroys(calls).flatMap(a => a.destroy as string[])).not.toContain('theirs');
|
||||
expect([...parents.keys()].sort()).toEqual(['new1', 'new3', 'theirs']);
|
||||
});
|
||||
|
||||
it('has nothing to remove when the copy itself is refused', async () => {
|
||||
@@ -349,4 +422,15 @@ describe('FileNode write errors, names and types', () => {
|
||||
await client.copyFileNode('src', 'src', 'target');
|
||||
expect(sets(calls)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('reads each account\'s tree once for all the copies of one paste (Stalwart 1.0)', async () => {
|
||||
const { client, calls } = makeClient({ me: tree, grp: tree }, { capability: STALWART_10_CAPABILITY });
|
||||
const listings: FileNodeListings = new Map();
|
||||
await client.copyFileNode('src', 'src', 'grp:target', listings);
|
||||
await client.copyFileNode('f', 'f.txt', 'grp:target', listings);
|
||||
|
||||
// The source tree, then the target's for the depth check; not again per item.
|
||||
expect(calls.filter(([m]) => m === 'FileNode/get').map(([, a]) => a.accountId)).toEqual(['me', 'grp']);
|
||||
expect(sets(calls)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,6 +196,9 @@ describe('lite build helpers', () => {
|
||||
expect(readme).toContain('stalwart-cli update Http --field usePermissiveCors=true');
|
||||
expect(readme).toContain('{"update": {"singleton": {"usePermissiveCors": true}}}');
|
||||
expect(readme).toContain('ReloadSettings');
|
||||
// The 1.0 pre-releases can't reload settings while the stock spam-filter
|
||||
// rules use a function 1.0 removed; a restart applies the change there.
|
||||
expect(readme).toMatch(/On the current 1\.0 pre-releases `ReloadSettings` fails while the\s+stock spam-filter rules are loaded, so restart Stalwart there instead\./);
|
||||
expect(readme).not.toContain('permissive-cors = true');
|
||||
expect(readme).toContain('currently `https://m.example`');
|
||||
});
|
||||
@@ -599,13 +602,17 @@ describe('Stalwart target: the entry document', () => {
|
||||
const readme = buildStalwartReadme({ version: '1.10.0', commit: 'abc1234', locales: ['en'], buildId: 'b' });
|
||||
expect(readme).toContain('Requires: Stalwart 0.16.6 or later (0.16.19 for `oauthClientId`).');
|
||||
// Only 0.16.23 keeps the running bundle; 0.16.0-0.16.22 and the 1.0
|
||||
// pre-releases unmount the Application, and a restart with an expired cache
|
||||
// and an unreachable URL leaves it unmounted everywhere.
|
||||
// pre-releases unmount the Application and delete the cached zip, so any
|
||||
// later unpack downloads again: a restart, UpdateApps, or a reload of the
|
||||
// Applications (cluster peers run one after UpdateApps). A restart with an
|
||||
// expired cache and an unreachable URL leaves it unmounted everywhere.
|
||||
expect(readme).not.toContain('A failed download keeps the previous bundle online');
|
||||
expect(readme).toMatch(/On 0\.16\.23 the\s+running bundle stays online/);
|
||||
expect(readme).toMatch(/Older 0\.16 releases and the 1\.0 pre-releases\s+unmount the Application until a later `UpdateApps` succeeds/);
|
||||
expect(readme).toMatch(/On every\s+version, a restart after the cached copy has expired/);
|
||||
expect(readme).toMatch(/check that `resourceUrl`\s+downloads before running `UpdateApps`/);
|
||||
expect(readme).not.toContain('until a later `UpdateApps` succeeds');
|
||||
expect(readme).toMatch(/Older 0\.16 releases and the 1\.0 pre-releases\s+unmount the Application and drop the cached copy\./);
|
||||
expect(readme).toMatch(/The next restart,\s+`UpdateApps` or reload of the Applications \(the other nodes of a cluster\s+reload them after `UpdateApps`\) downloads `resourceUrl` again and mounts the\s+bundle once that works\./);
|
||||
expect(readme).toMatch(/On every version, a restart after the cached copy\s+has expired/);
|
||||
expect(readme).toMatch(/check that `resourceUrl` downloads before running\s+`UpdateApps`/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { getWopiActions } from '@/lib/wopi/discovery';
|
||||
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
||||
import { fetchJmapSession, postJmap } from '@/lib/stalwart/jmap-api';
|
||||
import { updateFileNodeBlob } from '@/lib/wopi/files';
|
||||
import { shareeSavesNeedRename, updateFileNodeBlob } from '@/lib/wopi/files';
|
||||
import { mintWopiToken, wopiDocumentId } from '@/lib/wopi/token';
|
||||
|
||||
const ORIGIN = 'https://webmail.example.com';
|
||||
@@ -69,8 +69,14 @@ type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
let version: '0.16' | '1.0';
|
||||
let drives: Record<string, Record<string, FakeNode>>;
|
||||
// Accounts the user is a member of: their own, and a group drive if a test adds one.
|
||||
let memberOf: Set<string>;
|
||||
let sent: MethodCall[];
|
||||
|
||||
const filenodeAccount = (depth: number | null = version === '1.0' ? 64 : null) => ({
|
||||
accountCapabilities: { 'urn:ietf:params:jmap:filenode': { maxFileNodeDepth: depth, maxSizeFileNodeName: 255 } },
|
||||
});
|
||||
|
||||
const node = (id: string, rights = ALL_RIGHTS): FakeNode => ({
|
||||
id, parentId: null, name: 'Report.docx', type: DOCX, blobId: `G-old-${id}`, size: 10, modified: LOADED, myRights: rights,
|
||||
});
|
||||
@@ -78,9 +84,10 @@ const node = (id: string, rights = ALL_RIGHTS): FakeNode => ({
|
||||
/**
|
||||
* FileNode/get and FileNode/set the way Stalwart answers them. A blob can
|
||||
* only be used by members of the account it was uploaded into (Stalwart's
|
||||
* `has_access_blob`, the same on 0.16 and 1.0); the user is a member of
|
||||
* their own account only. 1.0 stamps `modified` on a content update only
|
||||
* when the patch asks for it and echoes it in `updated`; 0.16.6+ always
|
||||
* `has_access_blob`, the same on 0.16 and 1.0). A sharee needs
|
||||
* mayModifyContent for a content update on 1.0, and mayRename (the Modify
|
||||
* right) for any update on 0.16. 1.0 stamps `modified` on a content update
|
||||
* only when the patch asks for it and echoes it in `updated`; 0.16.6+ always
|
||||
* bump it and answer `updated: {id: null}`.
|
||||
*/
|
||||
function fakeJmapServer() {
|
||||
@@ -99,12 +106,21 @@ function fakeJmapServer() {
|
||||
if (method === 'FileNode/set') {
|
||||
const updated: Record<string, unknown> = {};
|
||||
const notUpdated: Record<string, unknown> = {};
|
||||
const sharee = !memberOf.has(args.accountId as string);
|
||||
for (const [id, patch] of Object.entries(args.update as Record<string, { blobId: string; modified?: null }>)) {
|
||||
if (!patch.blobId.startsWith(`G-${OWN}-`)) {
|
||||
if (!memberOf.has(patch.blobId.split('-')[1])) {
|
||||
notUpdated[id] = { type: 'forbidden', description: `You do not have access to blobId ${patch.blobId}.` };
|
||||
continue;
|
||||
}
|
||||
const target = drive[id];
|
||||
if (sharee && version === '0.16' && !target.myRights.mayRename) {
|
||||
notUpdated[id] = { type: 'forbidden', description: 'You are not allowed to modify this file node.' };
|
||||
continue;
|
||||
}
|
||||
if (sharee && version === '1.0' && !target.myRights.mayModifyContent) {
|
||||
notUpdated[id] = { type: 'forbidden', description: 'You are not allowed to modify the content of this file node.' };
|
||||
continue;
|
||||
}
|
||||
target.blobId = patch.blobId;
|
||||
target.size = 5;
|
||||
if (version === '1.0') {
|
||||
@@ -167,6 +183,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
version = '1.0';
|
||||
drives = { [OWN]: { f1: node('f1') }, [OWNER]: {} };
|
||||
memberOf = new Set([OWN]);
|
||||
sent = [];
|
||||
(getStalwartCredentials as Mock).mockResolvedValue({
|
||||
serverUrl: SERVER,
|
||||
@@ -177,12 +194,13 @@ beforeEach(() => {
|
||||
hasSessionCookie: true,
|
||||
});
|
||||
(getWopiActions as Mock).mockResolvedValue({ edit: { docx: OFFICE }, view: {} });
|
||||
(fetchJmapSession as Mock).mockResolvedValue({
|
||||
(fetchJmapSession as Mock).mockImplementation(async () => ({
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': OWN, 'urn:ietf:params:jmap:filenode': OWN },
|
||||
accounts: { [OWN]: {}, [OWNER]: {} },
|
||||
// 1.0 advertises maxFileNodeDepth 64, 0.16 null, on the shared account too.
|
||||
accounts: { [OWN]: filenodeAccount(), [OWNER]: filenodeAccount() },
|
||||
downloadUrl: 'https://public.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
||||
uploadUrl: 'https://public.example.com/jmap/upload/{accountId}/',
|
||||
});
|
||||
}));
|
||||
(fetchJmapServer as Mock).mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
const account = decodeURIComponent(new URL(url).pathname.split('/')[3]);
|
||||
@@ -286,16 +304,66 @@ describe('WOPI for a node in a shared drive', () => {
|
||||
expect(uploads()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps uploading into the document\'s account for tokens minted before the upload account existed', async () => {
|
||||
const payload = {
|
||||
serverUrl: SERVER, authHeader: 'Basic dXNlcjpwYXNz', username: 'user@example.com',
|
||||
accountId: OWN, fileId: 'f1', kind: 'file' as const, canWrite: true, origin: ORIGIN,
|
||||
};
|
||||
const documentId = wopiDocumentId(payload);
|
||||
const { token } = mintWopiToken(payload);
|
||||
// A share with the content right but not the rename right.
|
||||
const CONTENT_ONLY = { ...ALL_RIGHTS, mayRename: false, mayAddChildren: false, mayDelete: false, mayShare: false };
|
||||
|
||||
const res = await save({ documentId, token: `access_token=${encodeURIComponent(token)}` });
|
||||
it('opens read-only on 0.16 for a sharee who may change the content but not rename, since 0.16 refuses that save', async () => {
|
||||
version = '0.16';
|
||||
drives[OWNER].n5.myRights = CONTENT_ONLY;
|
||||
const { data } = await launchFile({ fileId: 'n5', accountId: OWNER });
|
||||
expect(data.readOnly).toBe(true);
|
||||
|
||||
// What an editable token would have run into on every save.
|
||||
const editable = mintEditableToken(OWNER, 'n5', OWN);
|
||||
expect((await save(editable)).status).toBe(500);
|
||||
expect(setCalls()).toEqual([{ accountId: OWNER, update: { n5: { blobId: `G-${OWN}-1`, modified: null } } }]);
|
||||
});
|
||||
|
||||
it('opens editable on Stalwart 1.0 for a sharee with the content right alone', async () => {
|
||||
drives[OWNER].n5.myRights = CONTENT_ONLY;
|
||||
const { data } = await launchFile({ fileId: 'n5', accountId: OWNER });
|
||||
expect(data.readOnly).toBe(false);
|
||||
expect(await save(editorCall(data))).toEqual({ status: 200, data: { LastModifiedTime: SAVED } });
|
||||
});
|
||||
|
||||
it('opens editable on 0.16 once the sharee may rename too', async () => {
|
||||
version = '0.16';
|
||||
drives[OWNER].n5.myRights = { ...CONTENT_ONLY, mayRename: true };
|
||||
const { data } = await launchFile({ fileId: 'n5', accountId: OWNER });
|
||||
expect(data.readOnly).toBe(false);
|
||||
expect(await save(editorCall(data))).toEqual({ status: 200, data: { LastModifiedTime: SAVED } });
|
||||
});
|
||||
|
||||
it('keeps uploading into the document\'s account for tokens minted before the upload account existed', async () => {
|
||||
// A group drive the user belongs to, so a blob uploaded there is theirs to use.
|
||||
memberOf.add(OWNER);
|
||||
const res = await save(mintEditableToken(OWNER, 'n5'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(uploads()).toEqual([`${SERVER}/jmap/upload/${OWN}/`]);
|
||||
expect(uploads()).toEqual([`${SERVER}/jmap/upload/${OWNER}/`]);
|
||||
expect(setCalls()).toEqual([{ accountId: OWNER, update: { n5: { blobId: `G-${OWNER}-1`, modified: null } } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareeSavesNeedRename', () => {
|
||||
it('asks a sharee for mayRename on 0.16 only, told apart by maxFileNodeDepth', () => {
|
||||
const session = (shared: object) => ({ accounts: { [OWN]: filenodeAccount(64), [OWNER]: shared } });
|
||||
expect(shareeSavesNeedRename({ accounts: { [OWN]: filenodeAccount(null), [OWNER]: filenodeAccount(null) } }, OWNER, OWN)).toBe(true);
|
||||
expect(shareeSavesNeedRename(session(filenodeAccount(64)), OWNER, OWN)).toBe(false);
|
||||
// A shared account that lists no filenode capability goes by the user's own.
|
||||
expect(shareeSavesNeedRename(session({}), OWNER, OWN)).toBe(false);
|
||||
// Without a session, the stricter rule: better read-only than an editor that can't save.
|
||||
expect(shareeSavesNeedRename(null, OWNER, OWN)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** An editable token; without `uploadAccountId` as launches minted before it existed. */
|
||||
function mintEditableToken(accountId: string, fileId: string, uploadAccountId?: string) {
|
||||
const payload = {
|
||||
serverUrl: SERVER, authHeader: 'Basic dXNlcjpwYXNz', username: 'user@example.com',
|
||||
accountId, fileId, kind: 'file' as const, canWrite: true, origin: ORIGIN,
|
||||
...(uploadAccountId ? { uploadAccountId } : {}),
|
||||
};
|
||||
const documentId = wopiDocumentId(payload);
|
||||
const { token } = mintWopiToken(payload);
|
||||
return { documentId, token: `access_token=${encodeURIComponent(token)}` };
|
||||
}
|
||||
|
||||
@@ -52,6 +52,19 @@ export function setPendingDeepLink(surface: AppSurface, segments: string[], sear
|
||||
pending.set(surface, { segments, search });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a link to a surface that is mounted and listening, and says whether
|
||||
* one was. Unlike setPendingDeepLink it never parks the link: without a
|
||||
* listener the caller navigates instead, and the surface reads the link from
|
||||
* the route when it mounts.
|
||||
*/
|
||||
export function deliverDeepLink(surface: AppSurface, segments: string[], search?: string): boolean {
|
||||
const live = listeners.get(surface);
|
||||
if (!live || live.size === 0) return false;
|
||||
setPendingDeepLink(surface, segments, search);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns the parked link once, then forgets it. */
|
||||
export function consumePendingDeepLinkEntry(surface: AppSurface): PendingDeepLink | null {
|
||||
const entry = pending.get(surface);
|
||||
@@ -67,10 +80,11 @@ export function consumePendingDeepLink(surface: AppSurface): string[] | null {
|
||||
|
||||
/**
|
||||
* Live delivery for an already-mounted surface (the Pro shell keeps surfaces
|
||||
* mounted for the whole session). Returns the unsubscribe function. Only the
|
||||
* embedded (Pro) instance of a surface should subscribe - during a cold-load
|
||||
* redirect the standard instance briefly renders too and must not steal the
|
||||
* link parked for the Pro instance that mounts right after.
|
||||
* mounted for the whole session). Returns the unsubscribe function. While the
|
||||
* Pro interface is on, only the embedded (Pro) instance of a surface should
|
||||
* subscribe - during a cold-load redirect the standard instance briefly
|
||||
* renders too and must not steal the link parked for the Pro instance that
|
||||
* mounts right after.
|
||||
*/
|
||||
export function subscribePendingDeepLink(
|
||||
surface: AppSurface,
|
||||
|
||||
+19
-2
@@ -380,6 +380,12 @@ export interface FilesDeepLink {
|
||||
path: string;
|
||||
/** File name to preview once the folder has loaded, if the link named one. */
|
||||
preview?: string;
|
||||
/**
|
||||
* A node of a drive shared with the user, or a page of such a drive, by the
|
||||
* id the Files store lists it under ("<owner>:<id>"). It has no path under
|
||||
* the user's own root; the Files app opens where it is listed instead.
|
||||
*/
|
||||
shared?: string;
|
||||
}
|
||||
|
||||
export function buildFilesPath(currentPath: string, preview?: string | null): string {
|
||||
@@ -388,14 +394,25 @@ export function buildFilesPath(currentPath: string, preview?: string | null): st
|
||||
return preview ? `${base}?preview=${encodeURIComponent(preview)}` : base;
|
||||
}
|
||||
|
||||
/** The query of a link into a shared drive (see FilesDeepLink.shared), leading `?` included. */
|
||||
export function sharedFilesSearch(id: string, preview?: string | null): string {
|
||||
const query = `?shared=${encodeURIComponent(id)}`;
|
||||
return preview ? `${query}&preview=${encodeURIComponent(preview)}` : query;
|
||||
}
|
||||
|
||||
export function buildSharedFilesPath(id: string, preview?: string | null): string {
|
||||
return `/files${sharedFilesSearch(id, preview)}`;
|
||||
}
|
||||
|
||||
export function parseFilesPath(
|
||||
segments: string[] = [],
|
||||
search?: URLSearchParams,
|
||||
): FilesDeepLink | null {
|
||||
const parts = segments.map(decodeSegment).filter(Boolean);
|
||||
const preview = search?.get('preview') ?? undefined;
|
||||
if (parts.length === 0 && !preview) return null;
|
||||
return { path: `/${parts.join('/')}`, preview };
|
||||
const shared = search?.get('shared') || undefined;
|
||||
if (parts.length === 0 && !preview && !shared) return null;
|
||||
return { path: `/${parts.join('/')}`, preview, ...(shared ? { shared } : {}) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+23
-1
@@ -1,4 +1,4 @@
|
||||
import { jmapError, jmapErrorProperties, jmapErrorType } from '@/lib/jmap/jmap-error';
|
||||
import { jmapError, jmapErrorProperties, jmapErrorType, type JmapError } from '@/lib/jmap/jmap-error';
|
||||
|
||||
/**
|
||||
* Why a FileNode write failed, from the SetError the JMAP client threw
|
||||
@@ -14,6 +14,28 @@ export function folderDepthError(): Error {
|
||||
return jmapError(TOO_DEEP, { type: 'invalidProperties', properties: ['parentId'] });
|
||||
}
|
||||
|
||||
type PartialCopyError = JmapError & { partialCopy?: boolean };
|
||||
|
||||
/**
|
||||
* The error for a copy that stopped part-way and could not be taken back
|
||||
* completely, so part of it is still there. It keeps the message and JMAP
|
||||
* type of `cause`, which say why the copy stopped.
|
||||
*/
|
||||
export function partialCopyError(cause: unknown): Error {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
const error: PartialCopyError = jmapError(message, {
|
||||
type: jmapErrorType(cause),
|
||||
properties: jmapErrorProperties(cause),
|
||||
});
|
||||
error.partialCopy = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Whether a failed copy left part of itself in place (see partialCopyError). */
|
||||
export function isPartialCopy(error: unknown): boolean {
|
||||
return (error as PartialCopyError | null | undefined)?.partialCopy === true;
|
||||
}
|
||||
|
||||
export function fileErrorKind(error: unknown): FileErrorKind | null {
|
||||
switch (jmapErrorType(error)) {
|
||||
// Stalwart 1.0, once an administrator limits files or folders per account.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FileHit } from '../types';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { appPath } from '@/lib/deep-links';
|
||||
|
||||
// Opening a Files hit: a node of a drive shared with the user has no path
|
||||
// under the login's own root, so its link names it by id.
|
||||
|
||||
const setPendingDeepLink = vi.fn();
|
||||
// Whether a Files page is open and listening (see deliverDeepLink).
|
||||
const deliverDeepLink = vi.fn((..._args: unknown[]) => false);
|
||||
vi.mock('@/lib/deep-link-handoff', () => ({
|
||||
setPendingDeepLink: (...args: unknown[]) => setPendingDeepLink(...args),
|
||||
deliverDeepLink: (...args: unknown[]) => deliverDeepLink(...args),
|
||||
}));
|
||||
|
||||
function storeHook(state: object) {
|
||||
const hook = (selector?: (s: object) => unknown) => (typeof selector === 'function' ? selector(state) : state);
|
||||
hook.getState = () => state;
|
||||
hook.setState = (patch: object) => Object.assign(state, patch);
|
||||
hook.subscribe = () => () => {};
|
||||
return hook;
|
||||
}
|
||||
|
||||
const authState = { activeAccountId: 'login-a', getClientForAccount: () => undefined, switchAccount: vi.fn() };
|
||||
const fileState = { currentAccountId: 'login-a', initClient: vi.fn() };
|
||||
const tabState = { openTab: vi.fn(), openEmailTab: vi.fn() };
|
||||
vi.mock('@/stores/auth-store', () => ({ useAuthStore: storeHook(authState) }));
|
||||
vi.mock('@/stores/file-store', () => ({ useFileStore: storeHook(fileState) }));
|
||||
vi.mock('@/stores/pro-tab-store', () => ({ useProTabStore: storeHook(tabState) }));
|
||||
|
||||
const { openHit } = await import('../open-hit');
|
||||
|
||||
const T = '2026-09-01T08:00:00Z';
|
||||
|
||||
function hit(node: Partial<FileNode> & Pick<FileNode, 'id' | 'name'>, folderPath: string, isFolder = false): FileHit {
|
||||
const full: FileNode = {
|
||||
parentId: null, type: isFolder ? '' : 'application/pdf', blobId: isFolder ? null : 'b1', size: 1, created: T, modified: T, ...node,
|
||||
};
|
||||
return {
|
||||
kind: 'files',
|
||||
localAccountId: 'login-a',
|
||||
jmapAccountId: full.accountId ?? 'me',
|
||||
id: full.id.includes(':') ? full.id.split(':')[1] : full.id,
|
||||
accountLabel: 'A',
|
||||
title: full.name,
|
||||
subtitle: folderPath,
|
||||
date: T,
|
||||
source: 'remote',
|
||||
node: full,
|
||||
folderPath,
|
||||
isFolder,
|
||||
};
|
||||
}
|
||||
|
||||
// A file shared on its own (its folder isn't shared), a shared folder, and a
|
||||
// file of the user's own drive.
|
||||
const sharedFile = hit({ id: 'owner1:n5', name: 'Q3 plan.pdf', parentId: 'owner1:d0', isShared: true, accountId: 'owner1' }, '/Documents');
|
||||
const sharedFolder = hit({ id: 'owner1:d1', name: 'Project X', isShared: true, accountId: 'owner1' }, '/', true);
|
||||
const ownFile = hit({ id: 'f1', name: 'a.txt', parentId: 'd1' }, '/Docs');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
deliverDeepLink.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('openHit for Files', () => {
|
||||
it('links a shared file by id, with the file to preview, in the standard layout', async () => {
|
||||
const navigate = vi.fn();
|
||||
await openHit(sharedFile, { proShell: false, navigate });
|
||||
expect(navigate).toHaveBeenCalledWith(appPath('/files?shared=owner1%3An5&preview=Q3%20plan.pdf'));
|
||||
|
||||
await openHit(sharedFolder, { proShell: false, navigate });
|
||||
expect(navigate).toHaveBeenLastCalledWith(appPath('/files?shared=owner1%3Ad1'));
|
||||
});
|
||||
|
||||
it('keeps linking the user\'s own files by path', async () => {
|
||||
const navigate = vi.fn();
|
||||
await openHit(ownFile, { proShell: false, navigate });
|
||||
expect(navigate).toHaveBeenCalledWith(appPath('/files/Docs?preview=a.txt'));
|
||||
});
|
||||
|
||||
it('hands the shared link to the Files tab in the Pro shell', async () => {
|
||||
await openHit(sharedFile, { proShell: true });
|
||||
expect(tabState.openTab).toHaveBeenCalledWith('files');
|
||||
expect(setPendingDeepLink).toHaveBeenCalledWith('files', [], '?shared=owner1%3An5&preview=Q3%20plan.pdf');
|
||||
});
|
||||
|
||||
it('hands the link to a Files page that is already open instead of navigating, in the standard layout', async () => {
|
||||
// Next keeps the page mounted when only the query changes, and the page
|
||||
// reads the route only when it mounts.
|
||||
deliverDeepLink.mockReturnValue(true);
|
||||
const navigate = vi.fn();
|
||||
await openHit(sharedFile, { proShell: false, navigate });
|
||||
expect(deliverDeepLink).toHaveBeenCalledWith('files', [], '?shared=owner1%3An5&preview=Q3%20plan.pdf');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates for a hit of another login, which has to switch logins first', async () => {
|
||||
deliverDeepLink.mockReturnValue(true);
|
||||
const navigate = vi.fn();
|
||||
await openHit({ ...sharedFile, localAccountId: 'login-b' }, { proShell: false, navigate });
|
||||
expect(authState.switchAccount).toHaveBeenCalledWith('login-b');
|
||||
expect(deliverDeepLink).not.toHaveBeenCalled();
|
||||
expect(navigate).toHaveBeenCalledWith(appPath('/files?shared=owner1%3An5&preview=Q3%20plan.pdf'));
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { setPendingDeepLink } from '@/lib/deep-link-handoff';
|
||||
import { deliverDeepLink, setPendingDeepLink } from '@/lib/deep-link-handoff';
|
||||
import {
|
||||
appPath,
|
||||
buildCalendarPath,
|
||||
buildContactsPath,
|
||||
buildFilesPath,
|
||||
buildMailPath,
|
||||
sharedFilesSearch,
|
||||
} from '@/lib/deep-links';
|
||||
import type { GlobalSearchHit } from '@/lib/global-search/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -21,6 +22,12 @@ export interface OpenHitOptions {
|
||||
}
|
||||
|
||||
function fileLinkFor(hit: Extract<GlobalSearchHit, { kind: 'files' }>): { segments: string[]; search?: string } {
|
||||
// A node of a drive shared with the user has no path under the login's own
|
||||
// root: the link names it by the id the Files store lists it under, and the
|
||||
// Files app opens the shared folder (or shared-files page) it is listed in.
|
||||
if (hit.node.isShared) {
|
||||
return { segments: [], search: sharedFilesSearch(hit.node.id, hit.isFolder ? null : hit.node.name) };
|
||||
}
|
||||
const folderSegments = hit.folderPath.split('/').filter(Boolean);
|
||||
if (hit.isFolder) return { segments: [...folderSegments, hit.node.name] };
|
||||
return { segments: folderSegments, search: `?preview=${encodeURIComponent(hit.node.name)}` };
|
||||
@@ -72,9 +79,7 @@ export async function openHit(hit: GlobalSearchHit, options: OpenHitOptions): Pr
|
||||
}
|
||||
}
|
||||
tabs.openTab('files');
|
||||
// A node in a shared drive has no path under the login's own root -
|
||||
// land on the drive root instead of a folder that doesn't resolve.
|
||||
const link = hit.node.isShared ? { segments: [] } : fileLinkFor(hit);
|
||||
const link = fileLinkFor(hit);
|
||||
setPendingDeepLink('files', link.segments, link.search);
|
||||
return;
|
||||
}
|
||||
@@ -95,9 +100,13 @@ export async function openHit(hit: GlobalSearchHit, options: OpenHitOptions): Pr
|
||||
navigate?.(appPath(buildCalendarPath({ view: 'month', date: null, eventId: hit.id, accountId: hit.jmapAccountId })));
|
||||
return;
|
||||
case 'files': {
|
||||
const link = hit.node.isShared ? { segments: [] as string[], search: undefined } : fileLinkFor(hit);
|
||||
const path = `/${link.segments.join('/')}`;
|
||||
navigate?.(appPath(buildFilesPath(path, hit.isFolder || hit.node.isShared ? null : hit.node.name)));
|
||||
const link = fileLinkFor(hit);
|
||||
// A Files page that is already open reads the route only when it
|
||||
// mounts, and Next keeps it mounted when a link changes only the query
|
||||
// (a node of a shared drive): it takes the link directly instead.
|
||||
if (isActiveLogin && deliverDeepLink('files', link.segments, link.search)) return;
|
||||
const path = buildFilesPath(`/${link.segments.join('/')}`);
|
||||
navigate?.(appPath(`${path}${link.search ?? ''}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ export interface CalendarQueryOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileNode trees by account, shared by the copies of one paste so that each
|
||||
* account is read once rather than once per pasted item (see copyFileNode).
|
||||
* Start each paste with an empty map.
|
||||
*/
|
||||
export type FileNodeListings = Map<string, Promise<FileNode[]>>;
|
||||
|
||||
export interface IJMAPClient {
|
||||
// ── Connection lifecycle ──────────────────────────────────────
|
||||
connect(): Promise<void>;
|
||||
@@ -541,7 +548,7 @@ export interface IJMAPClient {
|
||||
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
||||
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
|
||||
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||
copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise<FileNode>;
|
||||
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||
|
||||
+68
-17
@@ -1,7 +1,7 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||||
import { textConditions, textFilter } from "./search-utils";
|
||||
import { batched, batchedBySize, itemsPerRequest } from "./request-limits";
|
||||
@@ -23,7 +23,7 @@ import { isServerSetKeyword } from "./server-keywords";
|
||||
|
||||
export { SchedulingDeniedError };
|
||||
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
|
||||
import { folderDepthError } from "@/lib/file-errors";
|
||||
import { folderDepthError, partialCopyError } from "@/lib/file-errors";
|
||||
import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type";
|
||||
import { canonicalTimeZone, getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone";
|
||||
import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
|
||||
@@ -8876,12 +8876,22 @@ export class JMAPClient implements IJMAPClient {
|
||||
* drives. Blob/copy reserved the content to the target account, which only
|
||||
* that account's members may use: copying into someone else's shared drive
|
||||
* failed with it.
|
||||
*
|
||||
* `listings` lets the copies of one paste share the trees they read.
|
||||
*/
|
||||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||||
async copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise<FileNode> {
|
||||
const source = this.resolveFileNodeId(id);
|
||||
const target = this.resolveFileNodeId(parentId);
|
||||
const listing = (accountId: string): Promise<FileNode[]> => {
|
||||
let nodes = listings?.get(accountId);
|
||||
if (!nodes) {
|
||||
nodes = this.fetchAllFileNodes(accountId);
|
||||
listings?.set(accountId, nodes);
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const tree = await this.fetchAllFileNodes(source.accountId);
|
||||
const tree = await listing(source.accountId);
|
||||
const original = tree.find(n => n.id === source.id);
|
||||
if (!original) throw new Error('File node not found');
|
||||
// Symlinks (Stalwart 1.0) are left out: there is no content to copy, and
|
||||
@@ -8920,43 +8930,84 @@ export class JMAPClient implements IJMAPClient {
|
||||
1 + entry.children.reduce((tallest, child) => Math.max(tallest, height(child)), 0);
|
||||
const targetTree = target.accountId === source.accountId
|
||||
? tree
|
||||
: await this.fetchAllFileNodes(target.accountId).catch(() => null);
|
||||
: await listing(target.accountId).catch(() => null);
|
||||
if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) {
|
||||
throw folderDepthError();
|
||||
}
|
||||
}
|
||||
|
||||
const createCopy = (node: FileNode, name: string, parent: string | null): Promise<FileNode> => {
|
||||
// The ids this copy creates, by level below the copy (0 is the copy
|
||||
// itself), so that a failure takes back exactly those.
|
||||
const made: string[][] = [];
|
||||
const createCopy = async (node: FileNode, name: string, parent: string | null, level: number): Promise<FileNode> => {
|
||||
const props: Record<string, unknown> = { name };
|
||||
if (parent !== null) props.parentId = parent;
|
||||
if (!isFolderNode(node)) {
|
||||
props.type = this.fileNodeType(target.accountId, node.type);
|
||||
props.blobId = node.blobId;
|
||||
}
|
||||
return this.createFileNodeIn(target.accountId, props, 'copy');
|
||||
const created = await this.createFileNodeIn(target.accountId, props, 'copy');
|
||||
(made[level] ??= []).push(created.id);
|
||||
return created;
|
||||
};
|
||||
const copyChildren = async (entry: CopyEntry, parent: string): Promise<void> => {
|
||||
const copyChildren = async (entry: CopyEntry, parent: string, level: number): Promise<void> => {
|
||||
for (const child of entry.children) {
|
||||
const created = await createCopy(child.node, child.node.name, parent);
|
||||
await copyChildren(child, created.id);
|
||||
const created = await createCopy(child.node, child.node.name, parent, level);
|
||||
await copyChildren(child, created.id, level + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const root = await createCopy(plan.node, newName, target.id);
|
||||
const root = await createCopy(plan.node, newName, target.id, 0);
|
||||
const copy = this.namespaceFileNode(root, target.accountId);
|
||||
try {
|
||||
await copyChildren(plan, root.id);
|
||||
await copyChildren(plan, root.id, 1);
|
||||
} catch (error) {
|
||||
// Take the half-made copy away again (its children go with it) and
|
||||
// report what stopped it.
|
||||
await this.destroyFileNodes([copy.id]).catch((cleanup) => {
|
||||
console.warn('[Files] Could not remove a partly created copy:', cleanup instanceof Error ? cleanup.message : cleanup);
|
||||
});
|
||||
// Take the half-made copy away again and report what stopped it. A
|
||||
// large copy runs for minutes: whatever someone put into its folders
|
||||
// meanwhile stays, and so do the folders holding it.
|
||||
const kept = await this.destroyFileNodesBottomUp(target.accountId, made);
|
||||
if (kept > 0) {
|
||||
console.warn(`[Files] ${kept} node(s) of a failed copy were left in place`);
|
||||
throw partialCopyError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy nodes of one account level by level, the deepest first, without
|
||||
* onDestroyRemoveChildren, so a folder that holds anything else is refused
|
||||
* (`nodeHasChildren`) and stays. Stalwart 0.16 checks a destroy against the
|
||||
* tree as it was before the call and refuses a folder whose children go in
|
||||
* the same call (`willDestroy`), hence one call per level. Returns how many
|
||||
* of the nodes are still there.
|
||||
*/
|
||||
private async destroyFileNodesBottomUp(accountId: string, levels: string[][]): Promise<number> {
|
||||
let kept = 0;
|
||||
for (let level = levels.length - 1; level >= 0; level--) {
|
||||
for (const batch of batched(levels[level] ?? [], this.getMaxObjectsInSet())) {
|
||||
try {
|
||||
const response = await this.request(
|
||||
[["FileNode/set", { accountId, destroy: batch }, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
kept += batch.length;
|
||||
continue;
|
||||
}
|
||||
const refused = Object.values(result[1].notDestroyed || {}) as Array<{ type?: string } | null>;
|
||||
// Gone already is as good as destroyed.
|
||||
kept += refused.filter(err => err?.type !== "notFound").length;
|
||||
} catch {
|
||||
kept += batch.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
|
||||
const blob = await this.fetchBlob(blobId, name, type, accountId);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
+21
-1
@@ -1,4 +1,4 @@
|
||||
import { postJmap, fetchJmapSession, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { postJmap, fetchJmapSession, rebaseApiUrl, type JmapSessionDocument } from '@/lib/stalwart/jmap-api';
|
||||
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
||||
import { jmapError, type JmapErrorInfo } from '@/lib/jmap/jmap-error';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
@@ -52,6 +52,26 @@ async function callJmap(ctx: WopiJmapContext, methodCalls: unknown[]): Promise<J
|
||||
return json.methodResponses ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether saving into another account's node takes the Modify right
|
||||
* (`mayRename`) on top of `mayModifyContent`. Stalwart 0.16 refuses any
|
||||
* update a sharee makes without Modify in the node's own grants, which its
|
||||
* `myRights` reports, content changes included; 1.0 asks for the content
|
||||
* right alone. 1.0 advertises `maxFileNodeDepth` (64) and 0.16 null. A
|
||||
* shared account without the capability goes by the user's own.
|
||||
*/
|
||||
export function shareeSavesNeedRename(
|
||||
session: JmapSessionDocument | null,
|
||||
accountId: string,
|
||||
ownAccountId: string,
|
||||
): boolean {
|
||||
const filenode = (id: string) => {
|
||||
const account = session?.accounts?.[id] as { accountCapabilities?: Record<string, unknown> } | undefined;
|
||||
return account?.accountCapabilities?.['urn:ietf:params:jmap:filenode'] as { maxFileNodeDepth?: unknown } | undefined;
|
||||
};
|
||||
return typeof (filenode(accountId) ?? filenode(ownAccountId))?.maxFileNodeDepth !== 'number';
|
||||
}
|
||||
|
||||
export async function getWopiFileNode(
|
||||
ctx: WopiJmapContext,
|
||||
accountId: string,
|
||||
|
||||
Reference in New Issue
Block a user