Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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