Skip to content
Merged
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 });
}
});
});
37 changes: 28 additions & 9 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 Down Expand Up @@ -275,23 +292,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 +410,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
19 changes: 16 additions & 3 deletions packages/cli/src/capture/contactSheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ interface ContactSheetOptions {
quality?: number;
/** Target width per cell in pixels (default: 600) */
cellWidth?: number;
/** Cooperative boundary checked before starting each native Sharp page. */
remainingMs?: () => number;
}

/**
Expand All @@ -39,6 +41,8 @@ export async function createContactSheet(
cellWidth = 600,
} = opts;

if ((opts.remainingMs?.() ?? 1) <= 0) return null;

const files = imagePaths.slice(0, maxImages);
if (files.length === 0) return null;

Expand Down Expand Up @@ -125,6 +129,7 @@ function escapeXml(s: string): string {
* Output files: basePath → base-1.jpg, base-2.jpg, ...
* Returns the list of written file paths (empty if no images).
*/
// fallow-ignore-next-line complexity
async function createContactSheetPages(
imagePaths: string[],
outputBasePath: string,
Expand All @@ -133,14 +138,15 @@ async function createContactSheetPages(
customLabels?: string[],
): Promise<string[]> {
if (imagePaths.length === 0) return [];
const { pageSize = imagePaths.length, ...sheetOpts } = opts;
const { pageSize = imagePaths.length, remainingMs, ...sheetOpts } = opts;
const ext = outputBasePath.match(/\.[^.]+$/)?.[0] ?? ".jpg";
const base = outputBasePath.slice(0, -ext.length);

const pages = Math.ceil(imagePaths.length / pageSize);
const results: string[] = [];

for (let p = 0; p < pages; p++) {
if ((remainingMs?.() ?? 1) <= 0) break;
const chunk = imagePaths.slice(p * pageSize, (p + 1) * pageSize);
const chunkLabels = customLabels?.slice(p * pageSize, (p + 1) * pageSize);
const outPath = pages === 1 ? outputBasePath : `${base}-${p + 1}${ext}`;
Expand Down Expand Up @@ -170,6 +176,7 @@ async function createContactSheetPages(
export async function createScrollContactSheet(
screenshotsDir: string,
outputPath: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> {
if (!existsSync(screenshotsDir)) return [];

Expand All @@ -189,7 +196,7 @@ export async function createScrollContactSheet(
return createContactSheetPages(
paths,
outputPath,
{ cols: 3, cellWidth: 600, pageSize: 9 },
{ cols: 3, cellWidth: 600, pageSize: 9, ...budget },
0,
labels,
);
Expand All @@ -203,6 +210,7 @@ export async function createScrollContactSheet(
export async function createSnapshotContactSheet(
snapshotsDir: string,
outputPath: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> {
if (!existsSync(snapshotsDir)) return [];

Expand All @@ -222,7 +230,7 @@ export async function createSnapshotContactSheet(
return createContactSheetPages(
paths,
outputPath,
{ cols: 3, cellWidth: 600, pageSize: 9 },
{ cols: 3, cellWidth: 600, pageSize: 9, ...budget },
0,
labels,
);
Expand All @@ -236,6 +244,7 @@ export async function createSnapshotContactSheet(
export async function createAssetContactSheet(
assetsDir: string,
outputPath: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> {
if (!existsSync(assetsDir)) return [];

Expand All @@ -254,6 +263,7 @@ export async function createAssetContactSheet(
cellWidth: 480,
labelMode: "filename",
pageSize: 12,
...budget,
});
}

Expand All @@ -271,6 +281,7 @@ export async function createSvgContactSheet(
svgsDir: string,
outputPath: string,
assetsRootDir?: string,
budget: Pick<ContactSheetOptions, "remainingMs"> = {},
): Promise<string[]> {
const dirsToScan = [svgsDir, assetsRootDir].filter(
(d): d is string => d !== undefined && existsSync(d),
Expand Down Expand Up @@ -302,6 +313,7 @@ export async function createSvgContactSheet(
const labels: string[] = [];

for (let i = 0; i < svgPaths.length; i++) {
if ((budget.remainingMs?.() ?? 1) <= 0) break;
const svgPath = svgPaths[i]!;
const tmpPath = join(tmpDir, `.thumb-${i}.png`);
try {
Expand Down Expand Up @@ -334,6 +346,7 @@ export async function createSvgContactSheet(
cols: 5,
cellWidth: thumbSize,
pageSize: 15,
...budget,
},
0,
labels,
Expand Down
Loading
Loading