Files
webmail/lib/wopi/files.ts
T

246 lines
10 KiB
TypeScript

import { postJmap, fetchJmapSession, rebaseApiUrl, type JmapSessionDocument } from '@/lib/stalwart/jmap-api';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body';
import { jmapError, type JmapErrorInfo } from '@/lib/jmap/jmap-error';
import type { FileNode } from '@/lib/jmap/types';
import { downloadNameSegment } from '@/lib/jmap/download-name';
/**
* Server-side JMAP FileNode content I/O for the WOPI host (#425).
*
* The WOPI client calls CheckFileInfo/GetFile/PutFile server-to-server, so
* this module talks to the JMAP server directly with the credentials carried
* in the (encrypted) access token. Content is addressed purely via JMAP -
* blob download for GetFile, blob upload + `FileNode/set { blobId }` for
* PutFile - which sidesteps the DAV name-encoding quirks (#869) and works
* regardless of where the node sits in the folder tree.
*/
export interface WopiJmapContext {
serverUrl: string;
authHeader: string;
/** Mirrors StalwartCredentials.trusted - false routes via the rebinding-safe fetch. */
trusted: boolean;
}
const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:filenode'];
export const WOPI_FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'modified', 'myRights',
];
type JmapMethodResponse = [string, Record<string, unknown>, string];
async function callJmap(ctx: WopiJmapContext, methodCalls: unknown[]): Promise<JmapMethodResponse[]> {
const body = JSON.stringify({ using: USING, methodCalls });
const options = { trusted: ctx.trusted };
const directUrl = `${ctx.serverUrl}/jmap/`;
let response = await postJmap(directUrl, ctx.authHeader, body, options);
if (response.status === 404) {
// Same recovery as the JMAP passthrough (#627): resolve the session's
// advertised apiUrl rebased onto the reachable host and retry once.
const session = await fetchJmapSession(ctx.serverUrl, ctx.authHeader, options);
const apiUrl = rebaseApiUrl(session, ctx.serverUrl);
if (apiUrl && apiUrl !== directUrl) {
response = await postJmap(apiUrl, ctx.authHeader, body, options);
}
}
if (!response.ok) {
throw new Error(`JMAP request failed (${response.status})`);
}
const json = await readJsonCapped<{ methodResponses?: JmapMethodResponse[] }>(response, UPSTREAM_JSON_MAX_BYTES);
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,
fileId: string,
): Promise<FileNode | null> {
const responses = await callJmap(ctx, [
['FileNode/get', { accountId, ids: [fileId], properties: WOPI_FILE_NODE_PROPERTIES }, 'g0'],
]);
const [name, payload] = responses[0] ?? [];
if (name !== 'FileNode/get') return null;
const list = (payload?.list as FileNode[] | undefined) ?? [];
return list[0] ?? null;
}
// The session's download/upload templates advertise the server's public
// hostname, which this process may not be able to reach (see rebaseApiUrl) -
// keep the template's path but rebase onto the stored serverUrl's origin.
// Falls back to Stalwart's canonical paths when no session is available.
const FALLBACK_DOWNLOAD_PATH = '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
const FALLBACK_UPLOAD_PATH = '/jmap/upload/{accountId}/';
const blobUrlCache = new Map<string, { fetchedAt: number; downloadUrl: string; uploadUrl: string }>();
const BLOB_URL_TTL_MS = 5 * 60 * 1000;
function rebaseTemplate(template: string | undefined, serverUrl: string, fallbackPath: string): string {
const origin = new URL(serverUrl).origin;
if (!template) return origin + fallbackPath;
// Not parsed with `new URL` - the {placeholders} would get percent-encoded.
const path = template.replace(/^[a-z]+:\/\/[^/]+/i, '');
return origin + (path.startsWith('/') ? path : `/${path}`);
}
async function getBlobUrls(ctx: WopiJmapContext): Promise<{ downloadUrl: string; uploadUrl: string }> {
const cached = blobUrlCache.get(ctx.serverUrl);
if (cached && Date.now() - cached.fetchedAt < BLOB_URL_TTL_MS) return cached;
let downloadTemplate: string | undefined;
let uploadTemplate: string | undefined;
try {
const session = await fetchJmapSession(ctx.serverUrl, ctx.authHeader, { trusted: ctx.trusted }) as
| ({ downloadUrl?: string; uploadUrl?: string } & Record<string, unknown>)
| null;
downloadTemplate = session?.downloadUrl;
uploadTemplate = session?.uploadUrl;
} catch {
// Fall through to the canonical Stalwart paths.
}
const entry = {
fetchedAt: Date.now(),
downloadUrl: rebaseTemplate(downloadTemplate, ctx.serverUrl, FALLBACK_DOWNLOAD_PATH),
uploadUrl: rebaseTemplate(uploadTemplate, ctx.serverUrl, FALLBACK_UPLOAD_PATH),
};
blobUrlCache.set(ctx.serverUrl, entry);
return entry;
}
function expandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{([A-Za-z]+)\}/g, (match, key: string) =>
key in vars ? encodeURIComponent(vars[key]) : match,
);
}
/** Stream a blob's content from the JMAP server (WOPI GetFile). */
export async function downloadFileBlob(
ctx: WopiJmapContext,
accountId: string,
blobId: string,
name: string,
type: string,
signal?: AbortSignal,
): Promise<Response> {
const { downloadUrl } = await getBlobUrls(ctx);
const url = expandTemplate(downloadUrl, {
accountId,
blobId,
name: downloadNameSegment(name),
type: type || 'application/octet-stream',
});
return fetchJmapServer(url, {
method: 'GET',
headers: { Authorization: ctx.authHeader },
signal,
}, ctx.trusted);
}
/**
* Check that a mail attachment blob is readable with these credentials and
* learn its size (#1047). Only the response headers are read; the request
* is aborted before the body. Returns null when the blob cannot be downloaded.
*/
export async function probeBlob(
ctx: WopiJmapContext,
accountId: string,
blobId: string,
name: string,
type: string,
): Promise<{ size: number | null } | null> {
// Abort instead of awaiting body.cancel(): that await never settled
// against the Next dev server, hanging the launch until the browser gave up.
const controller = new AbortController();
const response = await downloadFileBlob(ctx, accountId, blobId, name, type, controller.signal);
controller.abort();
if (!response.ok) return null;
const length = Number(response.headers.get('Content-Length'));
return { size: Number.isFinite(length) && length >= 0 && response.headers.has('Content-Length') ? length : null };
}
/** Upload new content as a blob (first half of WOPI PutFile). */
export async function uploadFileBlob(
ctx: WopiJmapContext,
accountId: string,
body: ArrayBuffer,
contentType: string,
): Promise<{ blobId: string; size: number; type: string }> {
const { uploadUrl } = await getBlobUrls(ctx);
const url = expandTemplate(uploadUrl, { accountId });
const response = await fetchJmapServer(url, {
method: 'POST',
headers: {
Authorization: ctx.authHeader,
'Content-Type': contentType || 'application/octet-stream',
},
body,
}, ctx.trusted);
if (!response.ok) {
throw new Error(`Blob upload failed (${response.status})`);
}
const json = await readJsonCapped<{ blobId?: string; size?: number; type?: string }>(response, UPSTREAM_JSON_MAX_BYTES);
if (!json.blobId) throw new Error('Blob upload returned no blobId');
return { blobId: json.blobId, size: json.size ?? body.byteLength, type: json.type ?? contentType };
}
/**
* Point an existing FileNode at a new content blob (second half of PutFile).
* Returns the node's post-update `modified` timestamp.
*/
export async function updateFileNodeBlob(
ctx: WopiJmapContext,
accountId: string,
fileId: string,
blobId: string,
): Promise<{ modified: string }> {
const responses = await callJmap(ctx, [
// `modified: null` has the server stamp the save. Stalwart 1.0 treats
// `modified` as the client's and leaves it alone on a content change
// unless asked, so the list kept the old date and the conflict check
// against other JMAP writers never fired. 0.16.6+ bump it either way.
['FileNode/set', { accountId, update: { [fileId]: { blobId, modified: null } } }, 's0'],
['FileNode/get', { accountId, ids: [fileId], properties: ['id', 'modified'] }, 'g1'],
]);
const [setName, setPayload] = responses[0] ?? [];
if (setName !== 'FileNode/set') {
throw jmapError((setPayload?.description as string) || 'FileNode/set failed', setPayload as JmapErrorInfo | undefined);
}
const updated = setPayload?.updated as Record<string, { modified?: unknown } | null> | undefined;
if (!updated || !(fileId in updated)) {
const notUpdated = setPayload?.notUpdated as Record<string, JmapErrorInfo> | undefined;
throw jmapError(notUpdated?.[fileId]?.description || 'FileNode content update rejected', notUpdated?.[fileId]);
}
// 1.0 echoes the time it stamped; 0.16 answers `null` and the get reads it.
const stamped = updated[fileId]?.modified;
if (typeof stamped === 'string') return { modified: stamped };
const getPayload = responses[1]?.[1];
const node = ((getPayload?.list as FileNode[] | undefined) ?? [])[0];
return { modified: node?.modified ?? new Date().toISOString() };
}