Files
Michael Ramos 2a6fe5c457 Open files in external apps + code-review UX pass (#942)
Adds a server-side "open the current file in an external app" control to the code-review and annotate surfaces (split-button with host-detected apps; last-used becomes the default; cross-platform launch mirrored across the Bun and Pi runtimes), plus a code-review UX pass: file-header change letters and line counts, the diff-settings cog grouped with Split/Unified, an all-files collapse/expand-all toggle, and the semantic diff moved to a resizable sidebar accordion. Also fixes Cmd+click code navigation (worker token transformer).

Hardened over three adversarial review rounds: shared, tested path-containment for open-in (Bun + Pi), annotate scoping aligned to /api/doc reference roots, strict PR-checkout rooting (never the launch repo), deduped app-catalog and semantic-diff fetches, macOS bundle-only availability, Windows launch fixes (reveal + terminal off cmd's parser), and PR-checkout tracking across switch and pool warmup.
2026-06-19 08:56:06 -07:00

131 lines
4.8 KiB
TypeScript

import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve as resolvePath, posix as pathPosix } from "node:path";
import {
htmlAssetContentType,
rewriteCssAssetReferences,
rewriteHtmlAssetReferences,
} from "./html-assets";
export const MAX_HTML_ASSET_BYTES = 50 * 1024 * 1024;
export function inlineHtmlLocalAssets(html: string, htmlFilePath: string): string {
if (/^https?:\/\//i.test(htmlFilePath)) return html;
try {
const root = dirname(resolvePath(htmlFilePath));
const activeCss = new Set<string>();
const dataUrlFor = (assetPath: string): string | null => {
try {
const contentType = htmlAssetContentType(assetPath);
if (!contentType) return null;
const resolved = resolvePath(root, assetPath);
if (!isWithinDirectory(resolved, root)) return null;
if (!existsSync(resolved)) return null;
const stat = statSync(resolved);
if (!stat.isFile() || stat.size > MAX_HTML_ASSET_BYTES) return null;
let bytes = readFileSync(resolved);
if (contentType.startsWith("text/css") && !activeCss.has(assetPath)) {
activeCss.add(assetPath);
try {
const cssBase = pathPosix.dirname(assetPath);
const rewrittenCss = rewriteCssAssetReferences(
bytes.toString("utf-8"),
dataUrlFor,
cssBase === "." ? "" : cssBase,
);
bytes = Buffer.from(rewrittenCss, "utf-8");
} finally {
activeCss.delete(assetPath);
}
}
return `data:${contentType.replace(/;\s*/g, ";")};base64,${Buffer.from(bytes).toString("base64")}`;
} catch {
return null;
}
};
return rewriteHtmlAssetReferences(html, dataUrlFor);
} catch {
return html;
}
}
/**
* Single source of truth for "is this file inside this root?" containment used
* by every HTML asset / share-html sink (Bun route handler, share inliner, and
* the Pi server via the vendored copy). Resolves symlinks on BOTH the root and
* the target so an in-directory symlink pointing outside the root cannot escape.
* Keep all sinks importing this — duplicating it is how the escape was missed in
* one runtime before (#927/#929).
*/
export function isWithinDirectory(filePath: string, root: string): boolean {
let resolvedRoot: string;
try {
resolvedRoot = realpathSync(resolvePath(root));
} catch {
return false;
}
// Resolve symlinks on the asset so an in-directory symlink pointing outside
// the root (e.g. evil.css -> ~/.ssh/id_rsa) is rejected, not followed. A
// nonexistent target keeps the lexical path; the later read simply fails.
let resolved = resolvePath(filePath);
try {
resolved = realpathSync(resolved);
} catch {
// asset does not exist yet — fall through with the lexical path
}
const rel = relative(resolvedRoot, resolved);
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
}
/**
* Resolve the absolute file an /api/open-in request may launch and confirm it
* stays within an allowed root — the security boundary for the open-in
* endpoints, shared by the Bun and Pi servers so the resolve + containment
* can't drift between runtimes (same reason isWithinDirectory is single-sourced).
*
* `resolveRoot` may return one root or several: annotate scopes opens to the
* same set of reference roots `/api/doc` serves from, so any linked doc the
* user can view can also be opened. Root precedence: server-supplied root(s)
* override the client `base`; then `base`; then an absolute `filePath`'s own
* directory; then cwd. Each root resolves `filePath` independently, so a
* relative path is resolved correctly per-root (not only against the first).
* Returns the absolute path, or null when it escapes every allowed root.
*/
export function resolveOpenInTarget(
filePath: string,
base: string | null,
resolveRoot?: () => string | string[],
): string | null {
const provided = resolveRoot?.();
const roots = (
provided == null
? [
base
? resolvePath(base)
: isAbsolute(filePath)
? dirname(resolvePath(filePath))
: resolvePath(process.cwd()),
]
: Array.isArray(provided)
? provided
: [provided]
)
.filter((r): r is string => !!r)
.map((r) => resolvePath(r));
// Resolve against each root and return the first that contains its own
// resolution. For an absolute filePath the resolution is identical for every
// root (a pure containment check); for a relative filePath this avoids the
// duplicate-basename trap of resolving only against roots[0].
for (const root of roots) {
const abs = resolvePath(root, filePath);
if (isWithinDirectory(abs, root)) return abs;
}
return null;
}