From the final review: proxied archived HTML could run on our origin and read a remembered token; /api/upload accepted any Wayback URL and GIF-prefixed bytes; the origin check broke behind a TLS proxy; one unreadable snapshot could fail a run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
42 lines
1.7 KiB
TypeScript
42 lines
1.7 KiB
TypeScript
// Small, forgiving HTML helpers. Not a parser: enough for <meta>, <link>, <div style>, <img src>.
|
|
|
|
const NAMED: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
|
|
export function decodeEntities(s: string): string {
|
|
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
|
|
const e = entity.toLowerCase();
|
|
if (e.startsWith("#")) {
|
|
const code = e.startsWith("#x") ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
// Malformed pages carry entities like �: leave them as written rather than throw.
|
|
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
}
|
|
return NAMED[e] ?? match;
|
|
});
|
|
}
|
|
|
|
export function parseAttributes(tag: string): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
const body = tag.replace(/^<\s*[\w-]+/, "").replace(/\/?\s*>$/, "");
|
|
for (const m of body.matchAll(/([^\s=/"'>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) {
|
|
const name = m[1]!.toLowerCase();
|
|
if (!(name in out)) out[name] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? "");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function findTags(html: string, name: string): Record<string, string>[] {
|
|
return [...html.matchAll(new RegExp(`<${name}\\b[^>]*>`, "gi"))].map((m) => parseAttributes(m[0]));
|
|
}
|
|
|
|
/** `<meta property|name="key" content="…">` */
|
|
export function metaContent(html: string, key: string): string | undefined {
|
|
for (const a of findTags(html, "meta")) {
|
|
if ((a.property ?? a.name) === key && a.content) return a.content;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function textOf(fragment: string): string {
|
|
return decodeEntities(fragment.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
|
|
}
|