Skip to content

Commit 4781095

Browse files
Merge pull request heygen-com#2933 from heygen-com/docs/studio-5419-capture-skill-guards
docs(skills): gate blocked website captures
2 parents 6549845 + 462221a commit 4781095

21 files changed

Lines changed: 1798 additions & 191 deletions

packages/cli/src/capture/assetDownloader.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { isPrivateUrl, safeFetch, toStandaloneSvg } from "./assetDownloader.js";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import {
6+
downloadAndRewriteFonts,
7+
isPrivateUrl,
8+
safeFetch,
9+
toStandaloneSvg,
10+
} from "./assetDownloader.js";
311

412
describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
513
it("blocks loopback, private, and metadata IPv4", () => {
@@ -127,3 +135,52 @@ describe("toStandaloneSvg — scraped inline SVGs must survive as .svg files", (
127135
expect(toStandaloneSvg("<div>not an svg</div>")).toBe("<div>not an svg</div>");
128136
});
129137
});
138+
139+
describe("downloadAndRewriteFonts — attempt caps", () => {
140+
afterEach(() => vi.unstubAllGlobals());
141+
142+
async function expectFailedFontAttempts(css: string, expectedAttempts: number): Promise<void> {
143+
const dir = mkdtempSync(join(tmpdir(), "hf-font-attempts-"));
144+
const fetchMock = vi.fn(async () => new Response("failed", { status: 503 }));
145+
vi.stubGlobal("fetch", fetchMock);
146+
147+
try {
148+
await downloadAndRewriteFonts(css, dir);
149+
expect(fetchMock).toHaveBeenCalledTimes(expectedAttempts);
150+
} finally {
151+
rmSync(dir, { recursive: true, force: true });
152+
}
153+
}
154+
155+
it("counts failed requests toward the global 30-font cap", async () => {
156+
const css = Array.from(
157+
{ length: 35 },
158+
(_, i) =>
159+
`@font-face { font-family: Family${i}; src: url(https://fonts${i}.example/font-${i}.woff2); }`,
160+
).join("\n");
161+
await expectFailedFontAttempts(css, 30);
162+
});
163+
164+
it("counts failed requests toward the six-attempt per-family cap", async () => {
165+
const css = Array.from(
166+
{ length: 10 },
167+
(_, i) =>
168+
`@font-face { font-family: Shared; src: url(https://fonts.example/font-${i}.woff2); }`,
169+
).join("\n");
170+
await expectFailedFontAttempts(css, 6);
171+
});
172+
173+
it("does not start a font request after the capture budget is exhausted", async () => {
174+
const dir = mkdtempSync(join(tmpdir(), "hf-font-budget-"));
175+
const css = "@font-face { font-family: Budget; src: url(https://fonts.example/budget.woff2); }";
176+
const fetchMock = vi.fn();
177+
vi.stubGlobal("fetch", fetchMock);
178+
179+
try {
180+
await downloadAndRewriteFonts(css, dir, { remainingMs: () => 0 });
181+
expect(fetchMock).not.toHaveBeenCalled();
182+
} finally {
183+
rmSync(dir, { recursive: true, force: true });
184+
}
185+
});
186+
});

packages/cli/src/capture/assetDownloader.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { createHash } from "node:crypto";
1111
import type { DesignTokens, DownloadedAsset } from "./types.js";
1212
import type { CatalogedAsset } from "./assetCataloger.js";
1313

14+
interface DownloadBudgetOptions {
15+
remainingMs?: () => number;
16+
}
17+
1418
// SVGs: hash-of-bytes filename so it can't drift from content; label-derived names mis-assigned brands.
1519
function svgContentHashSlug(svgSource: string | Buffer, isLogo: boolean): string {
1620
const hash = createHash("sha1").update(svgSource).digest("hex").slice(0, 8);
@@ -43,11 +47,13 @@ export function toStandaloneSvg(outerHTML: string): string {
4347
return outerHTML.replace(original, tag);
4448
}
4549

50+
// fallow-ignore-next-line complexity
4651
export async function downloadAssets(
4752
tokens: DesignTokens,
4853
outputDir: string,
4954
catalogedAssets?: CatalogedAsset[],
5055
faviconLinks?: Array<{ rel: string; href: string }>,
56+
options: DownloadBudgetOptions = {},
5157
): Promise<DownloadedAsset[]> {
5258
const assetsDir = join(outputDir, "assets");
5359
mkdirSync(assetsDir, { recursive: true });
@@ -82,12 +88,14 @@ export async function downloadAssets(
8288

8389
// 2. Favicon
8490
for (const icon of faviconLinks || []) {
91+
const remainingMs = options.remainingMs?.() ?? 10_000;
92+
if (remainingMs <= 0) break;
8593
if (!icon.href) continue;
8694
try {
8795
const ext = extname(new URL(icon.href).pathname) || ".ico";
8896
const name = `favicon${ext}`;
8997
const localPath = `assets/${name}`;
90-
const buffer = await fetchBuffer(icon.href);
98+
const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs));
9199
if (buffer) {
92100
writeFileSync(join(outputDir, localPath), buffer);
93101
assets.push({ url: icon.href, localPath, type: "favicon" });
@@ -149,13 +157,15 @@ export async function downloadAssets(
149157
let imgIdx = 0;
150158
const usedNames = new Set<string>();
151159
for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
160+
const remainingMs = options.remainingMs?.() ?? 10_000;
161+
if (remainingMs <= 0) break;
152162
const batch = toDownload.slice(i, i + BATCH_SIZE);
153163
const results = await Promise.allSettled(
154164
batch.map(async ({ url, isPoster, catalog }) => {
155165
const parsedUrl = new URL(url);
156166
const pathExt = extname(parsedUrl.pathname);
157167
const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg";
158-
const buffer = await fetchBuffer(url);
168+
const buffer = await fetchBuffer(url, Math.min(10_000, remainingMs));
159169
if (!buffer) return null;
160170
const isSvg = ext === ".svg" || url.includes(".svg");
161171
const minSize = isSvg ? 200 : 10000;
@@ -198,10 +208,12 @@ export async function downloadAssets(
198208

199209
// 4. OG image (if not already downloaded)
200210
if (tokens.ogImage && !downloadedUrls.has(normalizeUrl(tokens.ogImage))) {
211+
const remainingMs = options.remainingMs?.() ?? 10_000;
201212
try {
202213
const ext = extname(new URL(tokens.ogImage).pathname) || ".jpg";
203214
const localPath = `assets/og-image${ext}`;
204-
const buffer = await fetchBuffer(tokens.ogImage);
215+
const buffer =
216+
remainingMs > 0 ? await fetchBuffer(tokens.ogImage, Math.min(10_000, remainingMs)) : null;
205217
if (buffer && buffer.length > 5000) {
206218
writeFileSync(join(outputDir, localPath), buffer);
207219
assets.push({ url: tokens.ogImage, localPath, type: "image" });
@@ -234,7 +246,12 @@ function normalizeUrl(u: string): string {
234246
* Download fonts referenced in CSS and rewrite URLs to local paths.
235247
* Returns the modified CSS string with local font paths.
236248
*/
237-
export async function downloadAndRewriteFonts(css: string, outputDir: string): Promise<string> {
249+
// fallow-ignore-next-line complexity
250+
export async function downloadAndRewriteFonts(
251+
css: string,
252+
outputDir: string,
253+
options: DownloadBudgetOptions = {},
254+
): Promise<string> {
238255
const assetsDir = join(outputDir, "assets", "fonts");
239256
mkdirSync(assetsDir, { recursive: true });
240257

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

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

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

277296
for (const fontUrl of sortedUrls) {
297+
const remainingMs = options.remainingMs?.() ?? 10_000;
298+
if (remainingMs <= 0) break;
278299
if (count >= MAX_TOTAL_FONTS) break;
279300
const family = getFamilyForUrl(fontUrl);
280301
const familyCount = familyCounts.get(family) || 0;
281302
if (familyCount >= MAX_FONTS_PER_FAMILY) continue;
303+
familyCounts.set(family, familyCount + 1);
304+
count++;
282305

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

289-
const buffer = await fetchBuffer(fontUrl);
312+
const buffer = await fetchBuffer(fontUrl, Math.min(10_000, remainingMs));
290313
if (buffer) {
291314
writeFileSync(localPath, buffer);
292315
rewritten = rewritten.split(fontUrl).join(relativePath);
293-
familyCounts.set(family, familyCount + 1);
294-
count++;
295316
}
296317
} catch {
297318
/* skip */
@@ -391,10 +412,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
391412
return null; // too many redirects
392413
}
393414

394-
async function fetchBuffer(url: string): Promise<Buffer | null> {
415+
async function fetchBuffer(url: string, timeoutMs = 10_000): Promise<Buffer | null> {
395416
try {
396417
const res = await safeFetch(url, {
397-
signal: AbortSignal.timeout(10000),
418+
signal: AbortSignal.timeout(timeoutMs),
398419
headers: { "User-Agent": "HyperFrames/1.0" },
399420
});
400421
if (!res || !res.ok) return null;

packages/cli/src/capture/contactSheet.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import { mkdtempSync, rmSync } from "node:fs";
1+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import sharp from "sharp";
55
import { describe, expect, it } from "vitest";
6-
import { createContactSheet } from "./contactSheet.js";
6+
import {
7+
createContactSheet,
8+
createScrollContactSheet,
9+
createSvgContactSheet,
10+
} from "./contactSheet.js";
711

812
function tempDir(): string {
913
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
@@ -58,3 +62,64 @@ describe("createContactSheet", () => {
5862
}
5963
}, 60_000);
6064
});
65+
66+
describe("contact-sheet capture budget", () => {
67+
it("does not start another Sharp page after the budget is exhausted", async () => {
68+
const dir = tempDir();
69+
try {
70+
for (let i = 0; i < 10; i++) {
71+
await sharp({
72+
create: {
73+
width: 4,
74+
height: 4,
75+
channels: 3,
76+
background: { r: i, g: i, b: i },
77+
},
78+
})
79+
.png()
80+
.toFile(join(dir, `scroll-${String(i).padStart(3, "0")}.png`));
81+
}
82+
83+
let checks = 0;
84+
const output = join(dir, "contact-sheet.jpg");
85+
const sheets = await createScrollContactSheet(dir, output, {
86+
remainingMs: () => (checks++ === 0 ? 1000 : 0),
87+
});
88+
89+
expect(sheets).toEqual([join(dir, "contact-sheet-1.jpg")]);
90+
expect(existsSync(join(dir, "contact-sheet-2.jpg"))).toBe(false);
91+
} finally {
92+
rmSync(dir, { recursive: true, force: true });
93+
}
94+
}, 60_000);
95+
96+
it("stops SVG thumbnail rasterization before the next native operation", async () => {
97+
const dir = tempDir();
98+
try {
99+
const svgs = join(dir, "svgs");
100+
const { mkdirSync } = await import("node:fs");
101+
mkdirSync(svgs);
102+
writeFileSync(
103+
join(svgs, "a.svg"),
104+
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="4" height="4"/></svg>',
105+
);
106+
writeFileSync(
107+
join(svgs, "b.svg"),
108+
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="2" cy="2" r="2"/></svg>',
109+
);
110+
let checks = 0;
111+
112+
const sheets = await createSvgContactSheet(
113+
svgs,
114+
join(dir, "svg-contact-sheet.jpg"),
115+
undefined,
116+
{ remainingMs: () => (checks++ === 0 ? 1000 : 0) },
117+
);
118+
119+
expect(sheets).toEqual([]);
120+
expect(checks).toBeGreaterThanOrEqual(2);
121+
} finally {
122+
rmSync(dir, { recursive: true, force: true });
123+
}
124+
}, 60_000);
125+
});

0 commit comments

Comments
 (0)