diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 09bd2ce7..99e2249a 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from '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 { BRANDING_OVERRIDE_KEYS, parseDomainBranding, @@ -528,7 +528,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} @@ -606,7 +606,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 9813c79d..45dfe938 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -1,17 +1,14 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; -import { Geist, Geist_Mono } from 'next/font/google'; -import '../globals.css'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], -}); +// The plugin sandbox iframe runs with an opaque origin (the `sandbox` +// attribute in production excludes `allow-same-origin` for isolation). Any +// 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: +// no font imports, no CSS imports. Plugins ship their own styles, and both the +// plugin bundle and all host API calls travel over the postMessage RPC bridge, +// so the sandbox never fetches same-origin assets itself. export const metadata: Metadata = { title: 'Plugin sandbox', @@ -21,10 +18,7 @@ export const metadata: Metadata = { export default function PluginSandboxLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 856650d0..168c65ac 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): headers['Authorization'] = client.getAuthHeader(); headers['X-JMAP-Username'] = client.getUsername(); } - const res = await fetch(url.pathname + url.search, { + const res = await apiFetch(url.pathname + url.search, { method: 'POST', headers, body: JSON.stringify(body), diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 90b0c6df..a0235ec8 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -10,6 +10,7 @@ import type { InstalledPlugin, SlotName } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH } from './protocol'; +import { withBasePath } from '../browser-navigation'; import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, } from './protocol'; @@ -143,7 +144,10 @@ export class SandboxInstance { this.iframe.style.width = '100%'; 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); window.addEventListener('message', this.listener);