Skip to content
59 changes: 58 additions & 1 deletion packages/cli/src/capture/assetDownloader.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isPrivateUrl, safeFetch, toStandaloneSvg } from "./assetDownloader.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
downloadAndRewriteFonts,
isPrivateUrl,
safeFetch,
toStandaloneSvg,
} from "./assetDownloader.js";

describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
it("blocks loopback, private, and metadata IPv4", () => {
Expand Down Expand Up @@ -127,3 +135,52 @@ describe("toStandaloneSvg — scraped inline SVGs must survive as .svg files", (
expect(toStandaloneSvg("<div>not an svg</div>")).toBe("<div>not an svg</div>");
});
});

describe("downloadAndRewriteFonts — attempt caps", () => {
afterEach(() => vi.unstubAllGlobals());

async function expectFailedFontAttempts(css: string, expectedAttempts: number): Promise<void> {
const dir = mkdtempSync(join(tmpdir(), "hf-font-attempts-"));
const fetchMock = vi.fn(async () => new Response("failed", { status: 503 }));
vi.stubGlobal("fetch", fetchMock);

try {
await downloadAndRewriteFonts(css, dir);
expect(fetchMock).toHaveBeenCalledTimes(expectedAttempts);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

it("counts failed requests toward the global 30-font cap", async () => {
const css = Array.from(
{ length: 35 },
(_, i) =>
`@font-face { font-family: Family${i}; src: url(https://fonts${i}.example/font-${i}.woff2); }`,
).join("\n");
await expectFailedFontAttempts(css, 30);
});

it("counts failed requests toward the six-attempt per-family cap", async () => {
const css = Array.from(
{ length: 10 },
(_, i) =>
`@font-face { font-family: Shared; src: url(https://fonts.example/font-${i}.woff2); }`,
).join("\n");
await expectFailedFontAttempts(css, 6);
});

it("does not start a font request after the capture budget is exhausted", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-font-budget-"));
const css = "@font-face { font-family: Budget; src: url(https://fonts.example/budget.woff2); }";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

try {
await downloadAndRewriteFonts(css, dir, { remainingMs: () => 0 });
expect(fetchMock).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
43 changes: 32 additions & 11 deletions packages/cli/src/capture/assetDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { createHash } from "node:crypto";
import type { DesignTokens, DownloadedAsset } from "./types.js";
import type { CatalogedAsset } from "./assetCataloger.js";

interface DownloadBudgetOptions {
remainingMs?: () => number;
}

// SVGs: hash-of-bytes filename so it can't drift from content; label-derived names mis-assigned brands.
function svgContentHashSlug(svgSource: string | Buffer, isLogo: boolean): string {
const hash = createHash("sha1").update(svgSource).digest("hex").slice(0, 8);
Expand Down Expand Up @@ -43,11 +47,13 @@ export function toStandaloneSvg(outerHTML: string): string {
return outerHTML.replace(original, tag);
}

// fallow-ignore-next-line complexity
export async function downloadAssets(
tokens: DesignTokens,
outputDir: string,
catalogedAssets?: CatalogedAsset[],
faviconLinks?: Array<{ rel: string; href: string }>,
options: DownloadBudgetOptions = {},
): Promise<DownloadedAsset[]> {
const assetsDir = join(outputDir, "assets");
mkdirSync(assetsDir, { recursive: true });
Expand Down Expand Up @@ -82,12 +88,14 @@ export async function downloadAssets(

// 2. Favicon
for (const icon of faviconLinks || []) {
const remainingMs = options.remainingMs?.() ?? 10_000;
if (remainingMs <= 0) break;
if (!icon.href) continue;
try {
const ext = extname(new URL(icon.href).pathname) || ".ico";
const name = `favicon${ext}`;
const localPath = `assets/${name}`;
const buffer = await fetchBuffer(icon.href);
const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs));
if (buffer) {
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: icon.href, localPath, type: "favicon" });
Expand Down Expand Up @@ -149,13 +157,15 @@ export async function downloadAssets(
let imgIdx = 0;
const usedNames = new Set<string>();
for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
const remainingMs = options.remainingMs?.() ?? 10_000;
if (remainingMs <= 0) break;
const batch = toDownload.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async ({ url, isPoster, catalog }) => {
const parsedUrl = new URL(url);
const pathExt = extname(parsedUrl.pathname);
const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg";
const buffer = await fetchBuffer(url);
const buffer = await fetchBuffer(url, Math.min(10_000, remainingMs));
if (!buffer) return null;
const isSvg = ext === ".svg" || url.includes(".svg");
const minSize = isSvg ? 200 : 10000;
Expand Down Expand Up @@ -198,10 +208,12 @@ export async function downloadAssets(

// 4. OG image (if not already downloaded)
if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) {
const remainingMs = options.remainingMs?.() ?? 10_000;
try {
const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg";
const localPath = `assets/og-image${ext}`;
const buffer = await fetchBuffer(tokens.ogImage);
const buffer =
remainingMs > 0 ? await fetchBuffer(tokens.ogImage, Math.min(10_000, remainingMs)) : null;
if (buffer && buffer.length > 5000) {
writeFileSync(join(outputDir, localPath), buffer);
assets.push({ url: tokens.ogImage, localPath, type: "image" });
Expand Down Expand Up @@ -234,7 +246,12 @@ function normalizeUrl(u: string): string {
* Download fonts referenced in CSS and rewrite URLs to local paths.
* Returns the modified CSS string with local font paths.
*/
export async function downloadAndRewriteFonts(css: string, outputDir: string): Promise<string> {
// fallow-ignore-next-line complexity
export async function downloadAndRewriteFonts(
css: string,
outputDir: string,
options: DownloadBudgetOptions = {},
): Promise<string> {
const assetsDir = join(outputDir, "assets", "fonts");
mkdirSync(assetsDir, { recursive: true });

Expand All @@ -247,8 +264,10 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P

if (fontUrls.size === 0) return css;

// Limit font downloads to avoid bloat. Google Fonts serves 20+ unicode-range
// subsets per weight — we only need a few per family for video production.
// Limit font download attempts to bound worst-case egress and latency. Google Fonts serves
// 20+ unicode-range subsets per weight, so successes alone cannot be the bound: six transient
// failures can intentionally suppress later URLs in that family. Latin-priority sorting below
// makes the limited attempts useful while keeping this failure tradeoff explicit.
const MAX_FONTS_PER_FAMILY = 6;
const MAX_TOTAL_FONTS = 30;
const familyCounts = new Map<string, number>();
Expand All @@ -275,23 +294,25 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P
let count = 0;

for (const fontUrl of sortedUrls) {
const remainingMs = options.remainingMs?.() ?? 10_000;
if (remainingMs <= 0) break;
if (count >= MAX_TOTAL_FONTS) break;
const family = getFamilyForUrl(fontUrl);
const familyCount = familyCounts.get(family) || 0;
if (familyCount >= MAX_FONTS_PER_FAMILY) continue;
familyCounts.set(family, familyCount + 1);
count++;

try {
const urlObj = new URL(fontUrl);
const filename = urlObj.pathname.split("/").pop() || `font-${count}.woff2`;
const localPath = join(assetsDir, filename);
const relativePath = `assets/fonts/${filename}`;

const buffer = await fetchBuffer(fontUrl);
const buffer = await fetchBuffer(fontUrl, Math.min(10_000, remainingMs));
if (buffer) {
writeFileSync(localPath, buffer);
rewritten = rewritten.split(fontUrl).join(relativePath);
familyCounts.set(family, familyCount + 1);
count++;
}
} catch {
/* skip */
Expand Down Expand Up @@ -391,10 +412,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
return null; // too many redirects
}

async function fetchBuffer(url: string): Promise<Buffer | null> {
async function fetchBuffer(url: string, timeoutMs = 10_000): Promise<Buffer | null> {
try {
const res = await safeFetch(url, {
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(timeoutMs),
headers: { "User-Agent": "HyperFrames/1.0" },
});
if (!res || !res.ok) return null;
Expand Down
69 changes: 67 additions & 2 deletions packages/cli/src/capture/contactSheet.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { createContactSheet } from "./contactSheet.js";
import {
createContactSheet,
createScrollContactSheet,
createSvgContactSheet,
} from "./contactSheet.js";

function tempDir(): string {
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
Expand Down Expand Up @@ -58,3 +62,64 @@ describe("createContactSheet", () => {
}
}, 60_000);
});

describe("contact-sheet capture budget", () => {
it("does not start another Sharp page after the budget is exhausted", async () => {
const dir = tempDir();
try {
for (let i = 0; i < 10; i++) {
await sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: { r: i, g: i, b: i },
},
})
.png()
.toFile(join(dir, `scroll-${String(i).padStart(3, "0")}.png`));
}

let checks = 0;
const output = join(dir, "contact-sheet.jpg");
const sheets = await createScrollContactSheet(dir, output, {
remainingMs: () => (checks++ === 0 ? 1000 : 0),
});

expect(sheets).toEqual([join(dir, "contact-sheet-1.jpg")]);
expect(existsSync(join(dir, "contact-sheet-2.jpg"))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 60_000);

it("stops SVG thumbnail rasterization before the next native operation", async () => {
const dir = tempDir();
try {
const svgs = join(dir, "svgs");
const { mkdirSync } = await import("node:fs");
mkdirSync(svgs);
writeFileSync(
join(svgs, "a.svg"),
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="4" height="4"/></svg>',
);
writeFileSync(
join(svgs, "b.svg"),
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="2" cy="2" r="2"/></svg>',
);
let checks = 0;

const sheets = await createSvgContactSheet(
svgs,
join(dir, "svg-contact-sheet.jpg"),
undefined,
{ remainingMs: () => (checks++ === 0 ? 1000 : 0) },
);

expect(sheets).toEqual([]);
expect(checks).toBeGreaterThanOrEqual(2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
Loading
Loading