mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat(plugins): binary Blob/File bodies for api.http.post
api.http.post(path, body, options) now accepts a Blob/File body and sends
it as-is to the same-origin route instead of JSON.stringify-ing it into
'{}'. The plugin may only supply Content-Type and X-Plugin-* metadata
headers; anything else is refused, and the host attaches its credential
headers after the plugin's, so they can never be overridden. Non-Blob
bodies keep the stock JSON behaviour. The sandbox facade previously
dropped the third argument, so the options never reached the host.
This commit is contained in:
@@ -232,7 +232,38 @@ function isApiPostPathAllowed(path: string, allowlist: readonly string[]): boole
|
||||
return false;
|
||||
}
|
||||
|
||||
async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
interface PluginHttpPostOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace a plugin must use for its own upload metadata headers. Anything
|
||||
* outside it (and `Content-Type`) is refused, so a plugin can never reach the
|
||||
* credential headers the host attaches to the request.
|
||||
*/
|
||||
const PLUGIN_HEADER_PREFIX = 'x-plugin-';
|
||||
|
||||
function applyPluginUploadHeaders(
|
||||
provided: Record<string, string> | undefined,
|
||||
target: Record<string, string>,
|
||||
): void {
|
||||
for (const [name, value] of Object.entries(provided ?? {})) {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower !== 'content-type' && !lower.startsWith(PLUGIN_HEADER_PREFIX)) {
|
||||
throw new Error(
|
||||
`Header ${name} is not allowed on a binary plugin upload (use Content-Type or an X-Plugin-* header)`,
|
||||
);
|
||||
}
|
||||
target[name] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function doHttpPost(
|
||||
plugin: InstalledPlugin,
|
||||
path: string,
|
||||
body: unknown,
|
||||
options?: PluginHttpPostOptions,
|
||||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
if (typeof path !== 'string' || !path.startsWith('/api/')) {
|
||||
throw new Error('path must start with /api/');
|
||||
}
|
||||
@@ -250,7 +281,25 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
|
||||
throw new Error(`Path ${url.pathname} not in plugin apiPostPaths allowlist`);
|
||||
}
|
||||
const { client } = useAuthStore.getState();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const headers: Record<string, string> = {};
|
||||
let requestBody: BodyInit;
|
||||
|
||||
if (body instanceof Blob) {
|
||||
// Binary upload: the plugin owns Content-Type and any X-Plugin-* metadata
|
||||
// the receiving route needs. Stock behaviour for every other body type is
|
||||
// unchanged - it is still serialized as JSON.
|
||||
applyPluginUploadHeaders(options?.headers, headers);
|
||||
const hasContentType = Object.keys(headers).some(h => h.toLowerCase() === 'content-type');
|
||||
if (!hasContentType && body.type) {
|
||||
headers['Content-Type'] = body.type;
|
||||
}
|
||||
requestBody = body;
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
requestBody = JSON.stringify(body);
|
||||
}
|
||||
|
||||
// Applied last so plugin-supplied headers can never override credentials.
|
||||
if (client) {
|
||||
headers['Authorization'] = client.getAuthHeader();
|
||||
headers['X-JMAP-Username'] = client.getUsername();
|
||||
@@ -258,7 +307,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
|
||||
const res = await apiFetch(url.pathname + url.search, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
body: requestBody,
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
@@ -795,7 +844,7 @@ export async function dispatchApiCall(
|
||||
case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
|
||||
case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
|
||||
|
||||
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
|
||||
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1], args[2] as PluginHttpPostOptions | undefined);
|
||||
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
|
||||
|
||||
case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined);
|
||||
|
||||
@@ -187,7 +187,11 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
logout: () => callApi('user.logout', []),
|
||||
},
|
||||
http: {
|
||||
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
||||
// A Blob/File body is sent as a binary request; options.headers may then
|
||||
// carry Content-Type and X-Plugin-* metadata for the receiving route.
|
||||
// Any other body keeps the stock JSON behaviour.
|
||||
post: (path: string, body: Record<string, unknown> | Blob, options?: { headers?: Record<string, string> }) =>
|
||||
callApi('http.post', [path, body, options]),
|
||||
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
|
||||
},
|
||||
// Privileged-tier only (same-origin plugins). Calls throw for untrusted
|
||||
@@ -225,6 +229,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/**
|
||||
* Used to alterate files before they are uploaded to server.
|
||||
* Edited files are saved on indexedDB and remove once the upload to server begins.
|
||||
* `get` needs the email:blob-read permission, `save` needs email:blob-write.
|
||||
*/
|
||||
upfiles: {
|
||||
save: (formerFileId:string, file:File) =>
|
||||
|
||||
Reference in New Issue
Block a user