7b5abc6ad9
Clicking an .html/.htm file now renders it instead of showing its source or downloading it: - Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor). - Chat file preview: a workspace page brings its relative files along. - File Explorer: double-click / Enter opens a viewer; Download stays on its header and right-click menu. The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never allow-same-origin). Its relative stylesheets (and their url()s), scripts, images, media and icons are read by the dashboard through the existing download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never above the folder's root, ≤ 60 files / 30 MB) — the login cookie is SameSite=Strict, so a frame could not fetch them itself, and no new route serves files without it.
231 lines
7.6 KiB
TypeScript
231 lines
7.6 KiB
TypeScript
/**
|
|
* @file htmlInline.ts
|
|
* @description An HTML file made whole for its sandboxed preview. The page
|
|
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
|
|
* nothing from the dashboard — and the dashboard's login cookie is
|
|
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
|
|
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
|
|
* the `url(...)`s inside its CSS through a read route that already guards
|
|
* them (the caller's `fetchFile`: a workspace's or a home's download), and
|
|
* writes them into the page as `<style>` and `data:` URLs before it renders.
|
|
* Only paths inside the page's own place are followed (`..` above its root is
|
|
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
|
|
* all, so a big site never stalls the viewer. A reference that cannot be read
|
|
* is left as it was. `fetch()`/XHR from the page's own script, ES module
|
|
* imports and links to other pages still do not work.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
/** Reads one file by its path relative to the place's root; throws when it cannot. */
|
|
export type FetchFile = (relPath: string) => Promise<Blob>;
|
|
|
|
const MAX_ASSETS = 60;
|
|
const MAX_BYTES = 30 * 1024 * 1024;
|
|
|
|
const MIME: Record<string, string> = {
|
|
css: "text/css",
|
|
js: "text/javascript",
|
|
mjs: "text/javascript",
|
|
json: "application/json",
|
|
png: "image/png",
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
gif: "image/gif",
|
|
webp: "image/webp",
|
|
avif: "image/avif",
|
|
bmp: "image/bmp",
|
|
ico: "image/x-icon",
|
|
svg: "image/svg+xml",
|
|
woff: "font/woff",
|
|
woff2: "font/woff2",
|
|
ttf: "font/ttf",
|
|
otf: "font/otf",
|
|
mp4: "video/mp4",
|
|
webm: "video/webm",
|
|
mp3: "audio/mpeg",
|
|
wav: "audio/wav",
|
|
ogg: "audio/ogg",
|
|
};
|
|
|
|
const mimeOf = (p: string) =>
|
|
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
|
|
|
|
/** The folder part of a relative path ("" at the root). */
|
|
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
|
|
|
/**
|
|
* A page's reference as a path from the place's root, or null when it is not
|
|
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
|
|
* A leading `/` counts from the root, as a site served from it would.
|
|
*/
|
|
export function resolveRef(dir: string, ref: string): string | null {
|
|
const r = ref.trim();
|
|
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
|
|
let p = r.replace(/[?#].*$/, "");
|
|
try {
|
|
p = decodeURIComponent(p);
|
|
} catch {
|
|
// A stray % is a literal one.
|
|
}
|
|
const out: string[] = [];
|
|
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
|
|
if (!seg || seg === ".") continue;
|
|
if (seg === "..") {
|
|
if (!out.length) return null;
|
|
out.pop();
|
|
} else out.push(seg);
|
|
}
|
|
return out.length ? out.join("/") : null;
|
|
}
|
|
|
|
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
|
|
export function blobText(b: Blob): Promise<string> {
|
|
if (typeof b.text === "function") return b.text();
|
|
return new Promise((ok, fail) => {
|
|
const r = new FileReader();
|
|
r.onload = () => ok(String(r.result));
|
|
r.onerror = () => fail(r.error);
|
|
r.readAsText(b);
|
|
});
|
|
}
|
|
function blobBytes(b: Blob): Promise<ArrayBuffer> {
|
|
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
|
|
return new Promise((ok, fail) => {
|
|
const r = new FileReader();
|
|
r.onload = () => ok(r.result as ArrayBuffer);
|
|
r.onerror = () => fail(r.error);
|
|
r.readAsArrayBuffer(b);
|
|
});
|
|
}
|
|
|
|
function base64(buf: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buf);
|
|
let s = "";
|
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
}
|
|
return btoa(s);
|
|
}
|
|
|
|
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
|
|
|
|
/** Inline the relative resources of `html` (the file at `htmlPath`). */
|
|
export async function inlineHtml(
|
|
html: string,
|
|
htmlPath: string,
|
|
fetchFile: FetchFile
|
|
): Promise<string> {
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
const cache = new Map<string, Promise<Blob | null>>();
|
|
let count = 0;
|
|
let bytes = 0;
|
|
let changed = false;
|
|
|
|
const read = (rel: string): Promise<Blob | null> => {
|
|
let p = cache.get(rel);
|
|
if (!p) {
|
|
if (count >= MAX_ASSETS) return Promise.resolve(null);
|
|
count++;
|
|
p = fetchFile(rel)
|
|
.then((b) => {
|
|
if (bytes + b.size > MAX_BYTES) return null;
|
|
bytes += b.size;
|
|
return b;
|
|
})
|
|
.catch(() => null);
|
|
cache.set(rel, p);
|
|
}
|
|
return p;
|
|
};
|
|
const dataUrl = async (rel: string): Promise<string | null> => {
|
|
const b = await read(rel);
|
|
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
|
|
};
|
|
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
|
|
const inlineCss = async (css: string, dir: string): Promise<string> => {
|
|
const refs = new Map<string, string | null>();
|
|
for (const m of css.matchAll(URL_RE)) {
|
|
const rel = resolveRef(dir, m[2]!);
|
|
if (rel && !refs.has(rel)) refs.set(rel, null);
|
|
}
|
|
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
|
|
return css.replace(URL_RE, (all, _q, ref: string) => {
|
|
const rel = resolveRef(dir, ref);
|
|
const url = rel ? refs.get(rel) : null;
|
|
return url ? `url("${url}")` : all;
|
|
});
|
|
};
|
|
|
|
const dir = dirOf(htmlPath);
|
|
const jobs: Promise<void>[] = [];
|
|
|
|
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
|
|
const rel = resolveRef(dir, link.getAttribute("href")!);
|
|
if (!rel) continue;
|
|
jobs.push(
|
|
(async () => {
|
|
const b = await read(rel);
|
|
if (!b) return;
|
|
const style = doc.createElement("style");
|
|
const media = link.getAttribute("media");
|
|
if (media) style.setAttribute("media", media);
|
|
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
|
|
link.replaceWith(style);
|
|
changed = true;
|
|
})()
|
|
);
|
|
}
|
|
for (const style of doc.querySelectorAll("style")) {
|
|
const css = style.textContent || "";
|
|
if (!/url\(/i.test(css)) continue;
|
|
jobs.push(
|
|
inlineCss(css, dir).then((out) => {
|
|
if (out !== css) {
|
|
style.textContent = out;
|
|
changed = true;
|
|
}
|
|
})
|
|
);
|
|
}
|
|
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
|
|
const css = el.getAttribute("style")!;
|
|
jobs.push(
|
|
inlineCss(css, dir).then((out) => {
|
|
if (out !== css) {
|
|
el.setAttribute("style", out);
|
|
changed = true;
|
|
}
|
|
})
|
|
);
|
|
}
|
|
const attrs: [string, string][] = [
|
|
["script[src]", "src"],
|
|
["img[src]", "src"],
|
|
["source[src]", "src"],
|
|
["video[src]", "src"],
|
|
["audio[src]", "src"],
|
|
["video[poster]", "poster"],
|
|
["input[type='image' i][src]", "src"],
|
|
["link[rel~='icon' i][href]", "href"],
|
|
];
|
|
for (const [sel, attr] of attrs) {
|
|
for (const el of doc.querySelectorAll(sel)) {
|
|
const rel = resolveRef(dir, el.getAttribute(attr)!);
|
|
if (!rel) continue;
|
|
jobs.push(
|
|
dataUrl(rel).then((url) => {
|
|
if (!url) return;
|
|
el.setAttribute(attr, url);
|
|
// A srcset beside it would win over the inlined src and still miss.
|
|
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
|
|
changed = true;
|
|
})
|
|
);
|
|
}
|
|
}
|
|
await Promise.all(jobs);
|
|
if (!changed) return html;
|
|
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
|
|
return doctype + doc.documentElement.outerHTML;
|
|
}
|