mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
2a6fe5c457
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.
131 lines
4.8 KiB
TypeScript
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;
|
|
}
|