diff --git a/src/servers/api/dev-proxy/router.ts b/src/servers/api/dev-proxy/router.ts index 3dfb44c4..683fec32 100644 --- a/src/servers/api/dev-proxy/router.ts +++ b/src/servers/api/dev-proxy/router.ts @@ -74,6 +74,30 @@ async function isOwnerToken(token: string | null): Promise { const hasCookie = (req: Request): boolean => (req.headers.get('cookie') ?? '').split(';').some((part) => part.trim().startsWith(`${COOKIE}=`)); +/** + * Take a normal authenticated request and turn it into the cookie the iframe needs. + * + * This exists because the two ways of getting credentials into a frame are both guesses. A token on the + * iframe URL guesses which key the browser stored it under; a cookie that must already be there guesses + * that something set it earlier. The panel's own API client already knows how to authenticate — it sends + * the Authorization header on every other call it makes — so it calls this first, and the iframe that + * follows needs to carry nothing at all. + * + * `Authorization` rather than the URL, so the token never appears in an address, a log or a Referer. + */ +devProxyRouter.get('/session', async (ctx) => { + const header = ctx.req.header('authorization') ?? ''; + const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : null; + if (!(await isOwnerToken(bearer))) return unauthorised(); + + return new Response(JSON.stringify({ ok: true }), { + headers: { + 'content-type': 'application/json', + 'set-cookie': `${COOKIE}=1; Path=/api/dev-proxy; Max-Age=${COOKIE_TTL_S}; HttpOnly; SameSite=Lax`, + }, + }); +}); + devProxyRouter.all('/:port/*', async (ctx) => { const port = ctx.req.param('port'); if (!PORT_RE.test(port)) return new Response('Bad port', { status: 400 }); @@ -134,7 +158,15 @@ devProxyRouter.all('/:port/*', async (ctx) => { headers.append('set-cookie', `${COOKIE}=1; Path=/api/dev-proxy; Max-Age=${COOKIE_TTL_S}; HttpOnly; SameSite=Lax`); } - if (!rewritable) return new Response(res.body, { status: res.status, headers }); + if (!rewritable) { + // `fetch` decompressed this on the way in, so the upstream's `content-encoding` now describes a body + // that no longer exists and `content-length` counts bytes that are no longer there. The browser would + // try to inflate plain bytes. The rewrite branch below has always deleted both; this one did not, so + // every image, font and wasm file was served with a lie attached. + headers.delete('content-length'); + headers.delete('content-encoding'); + return new Response(res.body, { status: res.status, headers }); + } const text = await res.text(); const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix); diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx index 4bb66ba3..4a4323f5 100644 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Globe } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; import { useWorkspace } from '../../components/Workspace'; -import { iframeSrc, normalizeUrl, usePreview } from './use-preview'; +import { normalizeUrl, usePreview } from './use-preview'; /** * A live app, in a panel. @@ -54,7 +55,54 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => { ); } - return ( -