/** * What a link click inside a raw-HTML annotate document means. * * A srcdoc document has no URL of its own: its base URL is the PARENT page's, * so `` resolves onto the Plannotator server and the * catch-all answers with the app itself — the whole editor rendered inside the * annotated frame. The bridge therefore never lets the frame navigate and * hands the RAW href to the parent, which is the trust boundary; this module * is the pure decision it makes. * * Deliberately pure (no DOM, no fetch, no `window`): the caller passes the * server origin and the directories, so every branch is unit-testable. */ import { isReviewImagePath } from "@plannotator/core/diff-paths"; import { hasLinkedDocExtension } from "./markdownExtensions"; /** * Route the servers serve a raw-HTML page's support assets from. Spelled here * rather than imported because `@plannotator/shared/html-assets` is node-side * (parse5, `path`); `htmlLinkNavigation.test.ts` pins the two together. */ export const HTML_ASSET_ROUTE_PREFIX = "/api/html-assets"; /** Longest href the parent will look at. Matches the bridge's own cap. */ export const MAX_HTML_LINK_HREF_LENGTH = 2048; export type HtmlLinkIntent = /** * A local document to open in the linked-doc overlay. `path` is absolute. * `rendersHtml` is whether it will open as another raw-HTML surface rather * than as markdown, which is what decides whether the sidebar is revealed: * HTML surfaces keep it closed and carry their own Back control. */ | { kind: "document"; path: string; hash: string; rendersHtml: boolean } /** Another origin. Opens in a new tab; the frame never navigates. */ | { kind: "external"; url: string } /** * A local image inside the page's asset root, shown in the image lightbox. * `url` is the page's own `/api/html-assets//…` route, so the * lightbox reads exactly what the page itself can already load. */ | { kind: "image"; path: string; url: string; label: string } /** * A local file Plannotator will not open. `type`: not a document or image * (`.pdf`, `.zip`, …). `outside-asset-root`: an image outside the page's * own folder, which the asset route refuses. `no-asset-root`: an image on * a page served without an asset route (share links, converted pages). */ | { kind: "unsupported"; path: string; label: string; reason: HtmlLinkUnsupportedReason; } /** Nothing to do: empty, fragment-only, or a scheme we do not follow. */ | { kind: "ignored"; reason: HtmlLinkIgnoreReason }; export type HtmlLinkUnsupportedReason = "type" | "outside-asset-root" | "no-asset-root"; export type HtmlLinkIgnoreReason = | "empty" | "too-long" | "fragment" | "scheme" | "invalid" | "no-base"; export interface HtmlLinkContext { /** Directory of the document the click happened in. */ baseDir?: string | null; /** * Directory the session was opened from. Server-absolute paths * (`/01-entry-point.html`, and the same path spelled with the server's own * origin) resolve against this, because that is the site root the author * meant when they wrote a root-relative link. */ rootDir?: string | null; /** The Plannotator server's own origin, e.g. `http://localhost:19601`. */ serverOrigin: string; /** The session's `--markdown` preference: HTML is Turndowned by `/api/doc`, * so an `.html` target renders as markdown rather than as an HTML surface. */ convertHtml?: boolean; /** * The current page's asset root: the directory its `/api/html-assets` * token was minted for, and that token's route (`/api/html-assets//`). * Image links open in the lightbox only from inside `dir`. */ assetRoot?: { dir: string; url: string } | null; } /** Control characters never appear in a real href; they are how structure gets smuggled. */ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i; export function resolveHtmlLinkIntent( href: string, context: HtmlLinkContext, ): HtmlLinkIntent { if (typeof href !== "string") return { kind: "ignored", reason: "invalid" }; const raw = href.trim(); if (!raw) return { kind: "ignored", reason: "empty" }; if (raw.length > MAX_HTML_LINK_HREF_LENGTH) return { kind: "ignored", reason: "too-long" }; if (CONTROL_CHARS.test(raw)) return { kind: "ignored", reason: "invalid" }; // The bridge scrolls in-page fragments itself; one reaching here is a no-op. if (raw.startsWith("#")) return { kind: "ignored", reason: "fragment" }; // Scheme-relative (`//host/x`) and absolute URLs both go through URL // parsing, which is the only reliable way to compare origins. if (raw.startsWith("//") || HAS_SCHEME.test(raw)) { return resolveAbsolute(raw, context); } const rootRelative = raw.startsWith("/"); const base = rootRelative ? context.rootDir : context.baseDir; return resolveRelative(raw, base, context); } function resolveAbsolute(raw: string, context: HtmlLinkContext): HtmlLinkIntent { let url: URL; try { url = new URL(raw, context.serverOrigin); } catch { return { kind: "ignored", reason: "invalid" }; } if (url.protocol !== "http:" && url.protocol !== "https:") { // javascript:, data:, mailto:, tel:, file:, blob: — none of them name a // document this session can annotate, and none may navigate the frame. return { kind: "ignored", reason: "scheme" }; } if (url.origin !== context.serverOrigin) { return { kind: "external", url: url.href }; } // The server's own origin: the author wrote `http://localhost:/x.html` // meaning "the file x.html of this site", so treat it as root-relative. // Left alone it would hit the catch-all and render the app in the frame. return resolveRelative(`${url.pathname}${url.hash}`, context.rootDir, context); } function resolveRelative( raw: string, baseDir: string | null | undefined, context: HtmlLinkContext, ): HtmlLinkIntent { const { path: pathPart, hash } = splitHash(stripQuery(raw)); if (!pathPart) return { kind: "ignored", reason: "fragment" }; const decoded = decodePath(pathPart); if (decoded === null || CONTROL_CHARS.test(decoded)) { return { kind: "ignored", reason: "invalid" }; } if (!baseDir) return { kind: "ignored", reason: "no-base" }; const resolved = joinPath(baseDir, decoded); if (!resolved) return { kind: "ignored", reason: "invalid" }; const label = basename(resolved); if (isReviewImagePath(resolved)) return resolveImage(resolved, label, context.assetRoot); // Containment is the server's call (`/api/doc` answers 403 for an escaping // path). The extension gate is ours: a `.pdf` would be a pointless fetch // and a confusing server error, so it is reported as unsupported here. if (!hasLinkedDocExtension(resolved)) { return { kind: "unsupported", path: resolved, label, reason: "type" }; } return { kind: "document", path: resolved, hash, rendersHtml: documentRendersHtml(resolved, context.convertHtml), }; } /** * An image link opens in the lightbox, read through the page's own asset * route. That route serves only files below the token's directory (it refuses * `..` and checks the realpath), so an image outside it is reported here * instead of becoming a request the server would refuse. This check is * lexical and only decides the UI; the server's check is the one that holds. */ function resolveImage( path: string, label: string, assetRoot: HtmlLinkContext["assetRoot"], ): HtmlLinkIntent { if (!assetRoot?.dir || !assetRoot.url) { return { kind: "unsupported", path, label, reason: "no-asset-root" }; } const dir = assetRoot.dir.replace(/\\/g, "/").replace(/\/+$/, ""); if (!path.startsWith(`${dir}/`)) { return { kind: "unsupported", path, label, reason: "outside-asset-root" }; } const relative = path .slice(dir.length + 1) .split("/") .map(encodeURIComponent) .join("/"); const base = assetRoot.url.endsWith("/") ? assetRoot.url : `${assetRoot.url}/`; return { kind: "image", path, url: `${base}${relative}`, label }; } /** * The asset route a served raw-HTML page is anchored at. Both servers install * `` first in `` (re-anchoring a * relative author base under the same token). Returns that token's route, or * null when the page carries none (share links, an author's absolute base). * * Only the FIRST `` element counts, as in the browser, and comments are * skipped: a page cannot name a different token in a comment or a later base * tag and have the lightbox read through it. */ const HTML_COMMENT_PATTERN = new RegExp("|$)", "g"); export function htmlAssetRouteFromDocument(rawHtml: string | null | undefined): string | null { if (!rawHtml) return null; // RegExp constructor, not a literal: Semgrep's TS parser chokes on `