mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: let plugins stop an attachment saved to Files, as they can stop an upload
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { fileHooks } from '@/lib/plugin-hooks';
|
||||
import { SaveToFilesDialog } from '../save-to-files-dialog';
|
||||
|
||||
// "Save to Files" files an attachment in a folder of the Files app by its
|
||||
@@ -62,4 +63,21 @@ describe('SaveToFilesDialog', () => {
|
||||
fireEvent.click(saveHere);
|
||||
await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-1', 'application/pdf', 1234, null));
|
||||
});
|
||||
|
||||
// The dialog used to file the blob without the upload hooks, so a plugin
|
||||
// that checks what goes into Files never heard about it.
|
||||
it('asks plugins first, as an upload into Files does, and saves nothing when one says no', async () => {
|
||||
const createFileNode = vi.fn().mockResolvedValue({});
|
||||
const veto = vi.fn(() => false);
|
||||
const reg = fileHooks.onBeforeFileUpload.register('dlp', veto);
|
||||
const client = { listAllFileNodes: vi.fn().mockResolvedValue(nodes), createFileNode } as unknown as IJMAPClient;
|
||||
render(<SaveToFilesDialog attachment={attachment} client={client} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Documents'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save_here' }));
|
||||
|
||||
await waitFor(() => expect(veto).toHaveBeenCalledWith({ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }));
|
||||
expect(createFileNode).not.toHaveBeenCalled();
|
||||
reg.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FolderPickerDialog } from "@/components/files/folder-picker-dialog";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { fileNodeKind } from "@/lib/jmap/filenode-kind";
|
||||
import { TRASH_FOLDER_NAME } from "@/lib/files-trash";
|
||||
import { saveBlobsToFiles } from "@/lib/files-save";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
|
||||
@@ -34,6 +35,18 @@ interface SaveToFilesDialogProps {
|
||||
// Nothing is "where the items already are": every folder, My files too, can be picked.
|
||||
const NO_CURRENT_FOLDER = "#none";
|
||||
|
||||
/** The path of a folder from the picker's list ('/' for My files), for plugins. */
|
||||
function folderPathOf(folders: { id: string; name: string; parentId: string | null }[], id: string | null): string {
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let at = id ? byId.get(id) : undefined; at && !seen.has(at.id); at = at.parentId ? byId.get(at.parentId) : undefined) {
|
||||
seen.add(at.id);
|
||||
names.unshift(at.name);
|
||||
}
|
||||
return `/${names.join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Save to Files" for an attachment: pick a folder with the Files app's
|
||||
* folder picker. The attachment's blob is already on the server, so it is
|
||||
@@ -75,17 +88,12 @@ export function SaveToFilesDialog({ attachment, client, blobAccountId, onClose }
|
||||
if (!attachment?.blobId) return;
|
||||
const folderName = parentId ? (folders.find((f) => f.id === parentId)?.name ?? "") : tFiles("my_files");
|
||||
try {
|
||||
const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
|
||||
const blobId = blobAccountId && filesAccountId && blobAccountId !== filesAccountId
|
||||
? (await client.copyBlobs(blobAccountId, [attachment.blobId], filesAccountId))[attachment.blobId]
|
||||
: attachment.blobId;
|
||||
await client.createFileNode(
|
||||
attachment.name || "attachment",
|
||||
blobId,
|
||||
attachment.type || "application/octet-stream",
|
||||
attachment.size,
|
||||
parentId,
|
||||
const { saved } = await saveBlobsToFiles(
|
||||
[{ blobId: attachment.blobId, name: attachment.name, type: attachment.type, size: attachment.size }],
|
||||
{ client, parentId, path: folderPathOf(folders, parentId), blobAccountId },
|
||||
);
|
||||
// A plugin stopped it (onBeforeFileUpload); it says why itself.
|
||||
if (saved.length === 0) return;
|
||||
toast.success(t("saved"), t("saved_to", { folder: folderName }));
|
||||
} catch (error) {
|
||||
console.error("Failed to save the attachment to Files:", error);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import { saveBlobsToFiles } from '../files-save';
|
||||
import { fileHooks } from '../plugin-hooks';
|
||||
|
||||
// Saving attachments to Files (the reader's Save to Files and
|
||||
// api.files.saveAttachments): filed by blob, copied over from another
|
||||
// account first, and announced to plugins like an upload in Files.
|
||||
|
||||
function makeClient() {
|
||||
let n = 0;
|
||||
return {
|
||||
getFilesAccountId: vi.fn(() => 'me'),
|
||||
copyBlobs: vi.fn(async (_from: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, `${id}-mine`]))),
|
||||
createFileNode: vi.fn(async (name: string, blobId: string, type: string, _size: number, parentId: string | null) => ({ id: `n${++n}`, name, blobId, type, parentId, size: 0, created: '', modified: '' })),
|
||||
};
|
||||
}
|
||||
const blobs = [
|
||||
{ blobId: 'b1', name: 'invoice.pdf', type: 'application/pdf', size: 1234 },
|
||||
{ blobId: 'b2', name: null, type: null, size: 9 },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
fileHooks.onBeforeFileUpload.clear();
|
||||
fileHooks.onAfterFileUpload.clear();
|
||||
});
|
||||
|
||||
describe('saveBlobsToFiles', () => {
|
||||
it('files each blob in the folder and tells plugins as for an upload', async () => {
|
||||
const client = makeClient();
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
fileHooks.onBeforeFileUpload.register('p', before);
|
||||
fileHooks.onAfterFileUpload.register('p', after);
|
||||
const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: 'docs', path: '/Documents' });
|
||||
expect(client.createFileNode.mock.calls).toEqual([
|
||||
['invoice.pdf', 'b1', 'application/pdf', 1234, 'docs'],
|
||||
['attachment', 'b2', 'application/octet-stream', 9, 'docs'],
|
||||
]);
|
||||
expect(client.copyBlobs).not.toHaveBeenCalled();
|
||||
expect(before.mock.calls).toEqual([
|
||||
[{ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }],
|
||||
[{ name: 'attachment', size: 9, type: 'application/octet-stream', path: '/Documents' }],
|
||||
]);
|
||||
await vi.waitFor(() => expect(after).toHaveBeenCalledTimes(2));
|
||||
expect(after.mock.calls[0][0]).toMatchObject({ name: 'invoice.pdf', type: 'file', path: '/Documents' });
|
||||
expect(saved.map((n) => [n.name, n.size])).toEqual([['invoice.pdf', 1234], ['attachment', 9]]);
|
||||
expect(stopped).toEqual([]);
|
||||
});
|
||||
|
||||
it('copies blobs of another account of the login into the Files account first', async () => {
|
||||
const client = makeClient();
|
||||
await saveBlobsToFiles([blobs[0]], { client: client as unknown as IJMAPClient, parentId: null, path: '/', blobAccountId: 'team' });
|
||||
expect(client.copyBlobs).toHaveBeenCalledWith('team', ['b1'], 'me');
|
||||
expect(client.createFileNode).toHaveBeenCalledWith('invoice.pdf', 'b1-mine', 'application/pdf', 1234, null);
|
||||
});
|
||||
|
||||
it('skips a file another plugin stops, and saves the rest', async () => {
|
||||
const client = makeClient();
|
||||
fileHooks.onBeforeFileUpload.register('dlp', (info: { name: string }) => info.name !== 'invoice.pdf');
|
||||
const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: null, path: '/' });
|
||||
expect(stopped).toEqual(['invoice.pdf']);
|
||||
expect(saved.map((n) => n.name)).toEqual(['attachment']);
|
||||
expect(client.createFileNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Save attachments of a message to Files: the blobs are already on the
|
||||
// server, so each is filed by blobId with no download or upload. From
|
||||
// another account of the login (a shared or group mailbox) the blob is
|
||||
// copied into the Files account first (Blob/copy, RFC 8620 §6.3).
|
||||
//
|
||||
// Used by the reader's Save to Files dialog and by the plugin host
|
||||
// (api.files.saveAttachments). Plugins hear about each file as about an
|
||||
// upload in Files: onBeforeFileUpload may stop it, onAfterFileUpload
|
||||
// reports it.
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { fileHooks } from '@/lib/plugin-hooks';
|
||||
import { fileToView } from '@/lib/plugin-projection';
|
||||
|
||||
/** What saving needs of an attachment: its blob, name, type and size. */
|
||||
export interface SaveableBlob {
|
||||
blobId: string;
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface SaveToFilesOptions {
|
||||
/** The mail login's client; its Files account takes the files. */
|
||||
client: IJMAPClient;
|
||||
/** The folder to save into; null is the top of the user's own drive. */
|
||||
parentId: string | null;
|
||||
/** The folder's path, for plugins ('/' for the top). */
|
||||
path: string;
|
||||
/**
|
||||
* The JMAP account the blobs are in, when not the Files account (a
|
||||
* shared or group mailbox of the same login).
|
||||
*/
|
||||
blobAccountId?: string;
|
||||
}
|
||||
|
||||
export interface SaveToFilesResult {
|
||||
/** The files created, with the names the server gave them. */
|
||||
saved: FileNode[];
|
||||
/** Names a plugin stopped (onBeforeFileUpload). */
|
||||
stopped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Files each blob into `parentId`. A name that is taken becomes
|
||||
* "name (2).ext" on the server. Stops at the first error and passes it on;
|
||||
* files saved before it stay.
|
||||
*/
|
||||
export async function saveBlobsToFiles(blobs: SaveableBlob[], { client, parentId, path, blobAccountId }: SaveToFilesOptions): Promise<SaveToFilesResult> {
|
||||
const saved: FileNode[] = [];
|
||||
const stopped: string[] = [];
|
||||
const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
|
||||
const copy = !!blobAccountId && !!filesAccountId && blobAccountId !== filesAccountId;
|
||||
for (const blob of blobs) {
|
||||
const name = blob.name || 'attachment';
|
||||
const type = blob.type || 'application/octet-stream';
|
||||
if (!(await fileHooks.onBeforeFileUpload.intercept({ name, size: blob.size, type, path }))) {
|
||||
stopped.push(name);
|
||||
continue;
|
||||
}
|
||||
const blobId = copy
|
||||
? (await client.copyBlobs(blobAccountId!, [blob.blobId], filesAccountId!))[blob.blobId]
|
||||
: blob.blobId;
|
||||
const node = await client.createFileNode(name, blobId, type, blob.size, parentId);
|
||||
// The server's answer to a create may leave the size out.
|
||||
saved.push(node.size ? node : { ...node, size: blob.size });
|
||||
void fileHooks.onAfterFileUpload.emit(fileToView(node, path));
|
||||
}
|
||||
return { saved, stopped };
|
||||
}
|
||||
Reference in New Issue
Block a user