Files
backnotprop__plannotator/packages/server/html-assets.ts
Michael Ramos 9c77c2f812 fix(server): always show remote URL + close share-html symlink gap (#929)
* fix(server): always show remote URL + close share-html symlink gap

Two pre-release fixes found by the QA sweep.

1. Remote URL stranding: handleServerReady only opened a browser (silent on
   a headless box) and wrote a ready-file; the user-visible URL came solely
   from writeRemoteShareLink, gated on sharingEnabled. So a remote/SSH user
   with sharing disabled (PLANNOTATOR_SHARE=disabled or config.json
   {share:"disabled"}, widened by #921) saw no URL and the agent hung on
   waitForDecision() forever. Now handleServerReady prints the reachable
   localhost URL whenever the session is remote, independent of sharing; the
   share link stays an extra. Pi/OpenCode already printed the URL
   unconditionally and are unaffected.

2. share-html symlink gap: the #927 symlink-containment fix hardened the
   /api/html-assets asset sinks but missed packages/server/annotate.ts's
   /api/share-html containment, which stayed lexical. A symlinked *.html
   inside the doc directory pointing outside it leaked the target's contents
   into the share payload. Now realpath-resolves both root and target like
   the other sinks. Bun-only — Pi's copy was already hardened.

Adds regression tests for both (handleServerReady remote stderr; share-html
returns 403 on symlink escape).

* refactor(server): single shared isWithinDirectory for all asset/share sinks

The symlink-containment check existed as four byte-identical copies (Bun
html-assets route, share inliner, annotate /api/share-html, Pi server). That
duplication is exactly why the escape was missed in one sink before — #927
hardened three and missed the fourth, and the #929 fix added a fifth-in-waiting.

Export the single canonical isWithinDirectory from the shared (Pi-vendored)
html-assets-node module and have every sink import it; delete the three
duplicate bodies and their now-dead realpathSync/relative/isAbsolute imports.
A new sink can no longer silently diverge. Regression tests + 690 server/shared
tests pass; build:pi clean.

* fix(pi): surface the session URL in the in-turn 'opened' notice for remote

Remote Pi users never saw a review/annotate/last URL: it was emitted from
openBrowserForServer AFTER the command's turn ended (fire-and-forget), and
Pi only renders a notify during an active turn — so it silently dropped.
The 'X opened. You can keep chatting' notice fires in-turn and DOES show,
so fold the URL into it for remote sessions (single-line, matching the
notify convention). Local sessions are unchanged (browser auto-opens).
2026-06-17 07:52:24 -07:00

102 lines
3.1 KiB
TypeScript

import { dirname, resolve as resolvePath } from "path";
import {
HTML_ASSET_ROUTE_PREFIX,
encodeHtmlAssetPath,
htmlAssetContentType,
normalizeHtmlAssetRoutePath,
rewriteHtmlAssetReferences,
} from "@plannotator/shared/html-assets";
import {
inlineHtmlLocalAssets,
isWithinDirectory,
MAX_HTML_ASSET_BYTES,
} from "@plannotator/shared/html-assets-node";
export { inlineHtmlLocalAssets };
export function createHtmlAssetRegistry() {
const rootsByToken = new Map<string, string>();
const tokensByRoot = new Map<string, string>();
function register(baseDir: string): string {
const root = resolvePath(baseDir);
const existing = tokensByRoot.get(root);
if (existing) return existing;
const token = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
tokensByRoot.set(root, token);
rootsByToken.set(token, root);
return token;
}
function rewriteHtml(html: string, htmlFilePath: string): string {
if (/^https?:\/\//i.test(htmlFilePath)) return html;
try {
const token = register(dirname(resolvePath(htmlFilePath)));
return rewriteHtmlAssetReferences(
html,
(assetPath) => `${HTML_ASSET_ROUTE_PREFIX}/${token}/${encodeHtmlAssetPath(assetPath)}`,
);
} catch {
return html;
}
}
function inlineHtml(html: string, htmlFilePath: string): string {
return inlineHtmlLocalAssets(html, htmlFilePath);
}
async function handle(_req: Request, url: URL): Promise<Response | null> {
const prefix = `${HTML_ASSET_ROUTE_PREFIX}/`;
if (!url.pathname.startsWith(prefix)) return null;
const rest = url.pathname.slice(prefix.length);
const slash = rest.indexOf("/");
if (slash <= 0) {
return Response.json({ error: "Missing asset token or path" }, { status: 404 });
}
const token = rest.slice(0, slash);
const root = rootsByToken.get(token);
if (!root) {
return Response.json({ error: "Unknown asset root" }, { status: 404 });
}
const assetPath = normalizeHtmlAssetRoutePath(rest.slice(slash + 1));
if (!assetPath) {
return Response.json({ error: "Invalid asset path" }, { status: 400 });
}
const contentType = htmlAssetContentType(assetPath);
if (!contentType) {
return Response.json({ error: "Unsupported asset type" }, { status: 415 });
}
const resolved = resolvePath(root, assetPath);
if (!isWithinDirectory(resolved, root)) {
return Response.json({ error: "Access denied" }, { status: 403 });
}
try {
const file = Bun.file(resolved);
if (!(await file.exists())) {
return Response.json({ error: "Asset not found" }, { status: 404 });
}
if (file.size > MAX_HTML_ASSET_BYTES) {
return Response.json({ error: "Asset too large" }, { status: 413 });
}
return new Response(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Access-Control-Allow-Origin": "*",
},
});
} catch {
return Response.json({ error: "Failed to read asset" }, { status: 500 });
}
}
return { rewriteHtml, inlineHtml, handle };
}