Fix: honour basePath in plugin sandbox, http.post proxy, and branding

Upstream 1.7.2 prefixes most hand-written URLs with basePath via apiFetch /
withBasePath, but four subpath-relevant spots were missed:

- host-bridge: the sandbox iframe src was a bare "/plugin-sandbox" -> 404
  under NEXT_PUBLIC_BASE_PATH, breaking all plugins. Wrap in withBasePath.
- host-api doHttpPost: the same-origin /api/* plugin proxy used raw fetch on
  url.pathname -> 404 under a subpath. Route it through apiFetch.
- admin branding preview <img>: unprefixed src -> broken thumbnail.
- (sandbox) layout: drop the Geist font + globals.css imports. The sandbox
  runs with an opaque origin, so those assets are CORS-blocked; the plugin
  bundle and all host API calls travel over the postMessage bridge, so no
  same-origin asset fetch happens there.
This commit is contained in:
dealerweb
2026-05-30 15:31:08 +02:00
committed by Linus Rath
parent 196e51e91b
commit f0d87d594a
4 changed files with 18 additions and 20 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { import {
BRANDING_OVERRIDE_KEYS, BRANDING_OVERRIDE_KEYS,
parseDomainBranding, parseDomainBranding,
@@ -528,7 +528,7 @@ export function BrandingTab() {
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" /> <ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2"> <div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img <img
src={currentValue(field.key)} src={withBasePath(currentValue(field.key))}
alt={field.label} alt={field.label}
className="max-h-6 max-w-[200px] object-contain" className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -606,7 +606,7 @@ export function BrandingTab() {
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" /> <ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2"> <div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img <img
src={currentValue(field.key)} src={withBasePath(currentValue(field.key))}
alt={field.label} alt={field.label}
className="max-h-6 max-w-[200px] object-contain" className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
+9 -15
View File
@@ -1,17 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import '../globals.css';
const geistSans = Geist({ // The plugin sandbox iframe runs with an opaque origin (the `sandbox`
variable: '--font-geist-sans', // attribute in production excludes `allow-same-origin` for isolation). Any
subsets: ['latin'], // asset request from this layout - bundled fonts, globals.css, etc. - is then
}); // cross-origin from the "null" origin to the host origin and gets blocked
// (fonts in particular require CORS). So this layout is intentionally minimal:
const geistMono = Geist_Mono({ // no font imports, no CSS imports. Plugins ship their own styles, and both the
variable: '--font-geist-mono', // plugin bundle and all host API calls travel over the postMessage RPC bridge,
subsets: ['latin'], // so the sandbox never fetches same-origin assets itself.
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Plugin sandbox', title: 'Plugin sandbox',
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
export default function PluginSandboxLayout({ children }: { children: ReactNode }) { export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body <body style={{ margin: 0, padding: 0, background: 'transparent' }}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
{children} {children}
</body> </body>
</html> </html>
+1 -1
View File
@@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
headers['Authorization'] = client.getAuthHeader(); headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername(); headers['X-JMAP-Username'] = client.getUsername();
} }
const res = await fetch(url.pathname + url.search, { const res = await apiFetch(url.pathname + url.search, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
+5 -1
View File
@@ -10,6 +10,7 @@
import type { InstalledPlugin, SlotName } from '../plugin-types'; import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api'; import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol'; import { SANDBOX_PATH } from './protocol';
import { withBasePath } from '../browser-navigation';
import type { import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload, SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol'; } from './protocol';
@@ -143,7 +144,10 @@ export class SandboxInstance {
this.iframe.style.width = '100%'; this.iframe.style.width = '100%';
this.iframe.style.height = '0px'; this.iframe.style.height = '0px';
} }
this.iframe.src = SANDBOX_PATH; // Prefix with the mount path so the sandbox route resolves under a
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
this.iframe.src = withBasePath(SANDBOX_PATH);
this.listener = (ev) => this.onMessage(ev); this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener); window.addEventListener('message', this.listener);