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:
Roman Varlakov
2026-08-06 17:25:42 +06:00
parent 656a9c9c3b
commit 600ab02034
2 changed files with 59 additions and 5 deletions
+53 -4
View File
@@ -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);
+6 -1
View File
@@ -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) =>