1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 14:24:26 -04:00

Add screenshot generation and integrate into domain report view (#32)

This commit is contained in:
2025-09-26 12:07:08 -04:00
committed by GitHub
parent d9c110ff7c
commit 70397e3c2a
23 changed files with 1770 additions and 108 deletions

View File

@@ -9,8 +9,8 @@ KV_REST_API_URL=
# Vercel Blob (add integration on Vercel; token for local/dev)
BLOB_READ_WRITE_TOKEN=
# Secret used to derive unpredictable blob paths for favicons
FAVICON_BLOB_SIGNING_SECRET=
# Secret used to derive unpredictable blob paths for generated images (favicons and screenshots)
BLOB_SIGNING_SECRET=
# Mapbox access token for react-map-gl
NEXT_PUBLIC_MAPBOX_TOKEN=

View File

@@ -0,0 +1,50 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@vercel/blob", () => ({
list: vi.fn(async (_opts: unknown) => ({
blobs: [
{ pathname: "favicons/1/abc/32.png", url: "https://blob/f1" },
{ pathname: "favicons/999999/def/32.png", url: "https://blob/f2" },
{ pathname: "screenshots/1/ghi/1200x630.png", url: "https://blob/s1" },
],
cursor: undefined,
})),
del: vi.fn(async (_url: string) => undefined),
}));
import { GET } from "./route";
describe("/api/cron/blob-prune", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("requires secret and prunes old buckets (GET)", async () => {
process.env.CRON_SECRET = "test-secret";
// Force nowBucket to be large so bucket=1 items are considered old
const realNow = Date.now;
Date.now = () => 10_000_000_000_000;
const req = new Request("http://localhost/api/cron/blob-prune", {
method: "GET",
headers: { authorization: "Bearer test-secret" },
});
const res = await GET(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.deletedCount).toBeGreaterThan(0);
// restore
Date.now = realNow;
});
it("rejects when secret missing or invalid (GET)", async () => {
delete process.env.CRON_SECRET;
const req = new Request("http://localhost/api/cron/blob-prune", {
method: "GET",
});
const res = await GET(req);
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,85 @@
import { del, list } from "@vercel/blob";
import { NextResponse } from "next/server";
import { getFaviconBucket, getScreenshotBucket } from "@/lib/blob";
export const runtime = "nodejs";
function getCronSecret(): string | null {
return process.env.CRON_SECRET || null;
}
function parseIntEnv(name: string, fallback: number): number {
const v = Number(process.env[name]);
return Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback;
}
// Keep last N buckets for safety
const KEEP_BUCKETS = () => parseIntEnv("BLOB_KEEP_BUCKETS", 2);
/**
* We prune old time-bucketed assets under `favicons/` and `screenshots/`.
* Paths are structured as: `${kind}/${bucket}/${digest}/...`.
*/
function shouldDeletePath(
pathname: string,
currentBucket: number,
keep: number,
) {
// Expect: kind/bucket/...
const parts = pathname.split("/");
if (parts.length < 3) return false;
const bucketNum = Number(parts[1]);
if (!Number.isFinite(bucketNum)) return false;
return bucketNum <= currentBucket - keep;
}
export async function GET(req: Request) {
const secret = getCronSecret();
const header = req.headers.get("authorization");
if (!secret || header !== `Bearer ${secret}`) {
return new NextResponse("unauthorized", { status: 401 });
}
const keep = KEEP_BUCKETS();
// Compute current bucket per kind using the same helpers as blob paths
const faviconBucket = getFaviconBucket();
const screenshotBucket = getScreenshotBucket();
const deleted: string[] = [];
const errors: Array<{ path: string; error: string }> = [];
// List favicons and screenshots prefixes separately to reduce listing size
for (const prefix of ["favicons/", "screenshots/"]) {
// Paginate list in case of many objects
let cursor: string | undefined;
do {
const res = await list({
prefix,
cursor,
token: process.env.BLOB_READ_WRITE_TOKEN,
});
cursor = res.cursor || undefined;
for (const item of res.blobs) {
const current = prefix.startsWith("favicons/")
? faviconBucket
: screenshotBucket;
if (shouldDeletePath(item.pathname, current, keep)) {
try {
await del(item.url, { token: process.env.BLOB_READ_WRITE_TOKEN });
deleted.push(item.pathname);
} catch (err) {
errors.push({
path: item.pathname,
error: (err as Error)?.message || "unknown",
});
}
}
}
} while (cursor);
}
return NextResponse.json({
deletedCount: deleted.length,
errorsCount: errors.length,
});
}

View File

@@ -13,6 +13,7 @@ import { DomainLoadingState } from "./domain-loading-state";
import { DomainUnregisteredState } from "./domain-unregistered-state";
import { exportDomainData } from "./export-data";
import { Favicon } from "./favicon";
import { ScreenshotTooltip } from "./screenshot-tooltip";
import { CertificatesSection } from "./sections/certificates-section";
import { DnsRecordsSection } from "./sections/dns-records-section";
import { HeadersSection } from "./sections/headers-section";
@@ -85,22 +86,24 @@ export function DomainReportView({
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Link
href={`https://${domain}`}
target="_blank"
rel="noopener"
className="flex items-center gap-2"
onClick={() =>
captureClient("external_domain_link_clicked", { domain })
}
>
<Favicon domain={domain} size={20} className="rounded" />
<h2 className="text-xl font-semibold tracking-tight">{domain}</h2>
<ExternalLink
className="h-4 w-4 text-muted-foreground/60"
aria-hidden="true"
/>
</Link>
<ScreenshotTooltip domain={domain}>
<Link
href={`https://${domain}`}
target="_blank"
rel="noopener"
className="flex items-center gap-2"
onClick={() =>
captureClient("external_domain_link_clicked", { domain })
}
>
<Favicon domain={domain} size={20} className="rounded" />
<h2 className="text-xl font-semibold tracking-tight">{domain}</h2>
<ExternalLink
className="h-4 w-4 text-muted-foreground/60"
aria-hidden="true"
/>
</Link>
</ScreenshotTooltip>
</div>
<div className="flex items-center gap-2">
<Button

View File

@@ -0,0 +1,93 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ScreenshotTooltip } from "./screenshot-tooltip";
// Mock tooltip primitives to render immediately without portals
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip">{children}</div>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button" data-slot="tooltip-trigger">
{children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip-content">{children}</div>
),
}));
vi.mock("next/image", () => ({
__esModule: true,
default: ({ alt, src }: { alt: string; src: string }) => (
// biome-ignore lint/performance/noImgElement: just a test
<img alt={alt} src={src} data-slot="image" />
),
}));
vi.mock("@/lib/trpc/client", () => ({
useTRPC: () => ({
domain: {
screenshot: {
queryOptions: (vars: unknown) => ({ queryKey: ["screenshot", vars] }),
},
},
}),
}));
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: vi.fn(),
};
});
const { useQuery } = await import("@tanstack/react-query");
describe("ScreenshotTooltip", () => {
beforeEach(() => {
(useQuery as unknown as Mock).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("fetches on open and shows loading UI", () => {
(useQuery as unknown as Mock).mockReturnValue({
data: undefined,
isLoading: true,
isFetching: false,
});
render(
<ScreenshotTooltip domain="example.com">
<span>hover me</span>
</ScreenshotTooltip>,
);
// Simulate open by clicking the trigger
fireEvent.click(screen.getByText("hover me"));
expect(screen.getByText(/loading screenshot/i)).toBeInTheDocument();
});
it("renders image when loaded", () => {
(useQuery as unknown as Mock).mockReturnValue({
data: { url: "https://blob/url.png" },
isLoading: false,
isFetching: false,
});
render(
<ScreenshotTooltip domain="example.com">
<span>hover me</span>
</ScreenshotTooltip>,
);
fireEvent.click(screen.getByText("hover me"));
const img = screen.getByRole("img", {
name: /homepage preview of example.com/i,
});
expect(img).toHaveAttribute("src", "https://blob/url.png");
});
});

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Screenshot } from "./screenshot";
export function ScreenshotTooltip({
domain,
children,
}: {
domain: string;
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const [hasOpened, setHasOpened] = React.useState(false);
return (
<Tooltip
open={open}
onOpenChange={(v) => {
setOpen(v);
if (v) setHasOpened(true);
}}
>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="bg-popover text-popover-foreground p-0 border shadow-xl"
hideArrow
>
<div className="w-[300px] sm:w-[360px] md:w-[420px]">
<Screenshot
domain={domain}
enabled={hasOpened}
aspectClassName="aspect-[4/2.1]"
/>
</div>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Screenshot } from "./screenshot";
vi.mock("next/image", () => ({
__esModule: true,
default: ({
alt,
src,
width,
height,
}: {
alt: string;
src: string;
width: number;
height: number;
}) =>
React.createElement("img", {
alt,
src,
width,
height,
"data-slot": "image",
}),
}));
vi.mock("@/lib/trpc/client", () => ({
useTRPC: () => ({
domain: {
screenshot: {
queryOptions: (vars: unknown) => ({ queryKey: ["screenshot", vars] }),
},
},
}),
}));
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: vi.fn(),
};
});
const { useQuery } = await import("@tanstack/react-query");
describe("Screenshot", () => {
beforeEach(() => {
(useQuery as unknown as Mock).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows loading UI during fetch", () => {
(useQuery as unknown as Mock).mockReturnValue({
data: undefined,
isLoading: true,
isFetching: false,
});
render(<Screenshot domain="example.com" />);
expect(screen.getByText(/loading screenshot/i)).toBeInTheDocument();
});
it("renders image when url present", () => {
(useQuery as unknown as Mock).mockReturnValue({
data: { url: "https://blob/url.png" },
isLoading: false,
isFetching: false,
});
render(<Screenshot domain="example.com" />);
const img = screen.getByRole("img", {
name: /homepage preview of example.com/i,
});
expect(img).toHaveAttribute("src", "https://blob/url.png");
});
it("shows fallback when no url and not loading", () => {
(useQuery as unknown as Mock).mockReturnValue({
data: { url: null },
isLoading: false,
isFetching: false,
});
render(<Screenshot domain="example.com" />);
expect(
screen.getByText(/unable to generate a preview/i),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,88 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import Image from "next/image";
import { useTRPC } from "@/lib/trpc/client";
import { cn } from "@/lib/utils";
export function Screenshot({
domain,
enabled = true,
className,
caption,
width = 1200,
height = 630,
imageClassName,
aspectClassName = "aspect-[1200/630]",
}: {
domain: string;
enabled?: boolean;
className?: string;
caption?: string;
width?: number;
height?: number;
imageClassName?: string;
aspectClassName?: string;
}) {
const trpc = useTRPC();
const { data, isLoading, isFetching } = useQuery(
trpc.domain.screenshot.queryOptions(
{ domain },
{
staleTime: 24 * 60 * 60_000, // 24h in ms
enabled,
},
),
);
const url = data?.url ?? null;
const loading = isLoading || isFetching;
return (
<div className={className}>
{loading && (
<div className="p-2">
<div
className={`w-full ${aspectClassName} rounded-md border bg-muted/50 flex items-center justify-center`}
>
<div
className="flex items-center gap-2 text-xs text-muted-foreground"
aria-live="polite"
>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading screenshot...</span>
</div>
</div>
</div>
)}
{!loading && url && (
<div className="p-2">
<Image
src={url}
alt={`Homepage preview of ${domain}`}
width={width}
height={height}
className={cn(
"rounded-md border h-auto w-full object-cover",
aspectClassName,
imageClassName,
)}
unoptimized
priority={false}
/>
{caption ? (
<div className="px-1 pt-1 text-[10px] text-muted-foreground">
{caption}
</div>
) : null}
</div>
)}
{!loading && !url && (
<div className="p-4 text-xs text-muted-foreground">
Unable to generate a preview.
</div>
)}
</div>
);
}

View File

@@ -38,8 +38,11 @@ function TooltipContent({
className,
sideOffset = 0,
children,
hideArrow,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
hideArrow?: boolean;
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
@@ -52,7 +55,9 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
{hideArrow ? null : (
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
)}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);

View File

@@ -10,8 +10,12 @@ vi.mock("@vercel/blob", () => ({
import {
computeFaviconBlobPath,
computeScreenshotBlobPath,
getScreenshotBucket,
headFaviconBlob,
headScreenshotBlob,
putFaviconBlob,
putScreenshotBlob,
} from "./blob";
const originalEnv = { ...process.env };
@@ -26,7 +30,7 @@ afterEach(() => {
describe("blob utils", () => {
it("computeFaviconBlobPath is deterministic and secret-dependent", () => {
process.env.FAVICON_BLOB_SIGNING_SECRET = "secret-a";
process.env.BLOB_SIGNING_SECRET = "secret-a";
const a1 = computeFaviconBlobPath("example.com", 32);
const a2 = computeFaviconBlobPath("example.com", 32);
expect(a1).toBe(a2);
@@ -34,22 +38,54 @@ describe("blob utils", () => {
const a3 = computeFaviconBlobPath("example.com", 64);
expect(a3).not.toBe(a1);
process.env.FAVICON_BLOB_SIGNING_SECRET = "secret-b";
process.env.BLOB_SIGNING_SECRET = "secret-b";
const b1 = computeFaviconBlobPath("example.com", 32);
expect(b1).not.toBe(a1);
expect(a1).toMatch(/^favicons\//);
});
it("headFaviconBlob returns URL on success and null on error", async () => {
it("computeScreenshotBlobPath is deterministic and secret-dependent", () => {
process.env.BLOB_SIGNING_SECRET = "secret-a";
const s1 = computeScreenshotBlobPath("example.com", 1200, 630);
const s2 = computeScreenshotBlobPath("example.com", 1200, 630);
expect(s1).toBe(s2);
process.env.BLOB_SIGNING_SECRET = "secret-b";
const s3 = computeScreenshotBlobPath("example.com", 1200, 630);
expect(s3).not.toBe(s1);
expect(s1).toMatch(/^screenshots\//);
});
it("paths include bucket segments and change when bucket changes", () => {
process.env.FAVICON_TTL_SECONDS = "10";
process.env.SCREENSHOT_TTL_SECONDS = "10";
const base = 1_000_000_000_000;
const realNow = Date.now;
Date.now = () => base;
const f1 = computeFaviconBlobPath("example.com", 32);
const s1 = computeScreenshotBlobPath("example.com", 1200, 630);
Date.now = () => base + 11_000;
const f2 = computeFaviconBlobPath("example.com", 32);
const s2 = computeScreenshotBlobPath("example.com", 1200, 630);
expect(f1).not.toBe(f2);
expect(s1).not.toBe(s2);
Date.now = realNow;
});
it("headFaviconBlob returns URL on success and null when both buckets miss", async () => {
const { head } = await import("@vercel/blob");
(head as unknown as import("vitest").Mock).mockResolvedValueOnce({
url: "https://blob/existing.png",
});
const url = await headFaviconBlob("example.com", 32);
expect(url).toBe("https://blob/existing.png");
(head as unknown as import("vitest").Mock).mockRejectedValueOnce(
new Error("fail"),
new Error("fail-current"),
);
(head as unknown as import("vitest").Mock).mockRejectedValueOnce(
new Error("fail-prev"),
);
const none = await headFaviconBlob("example.com", 32);
expect(none).toBeNull();
@@ -66,4 +102,64 @@ describe("blob utils", () => {
contentType: "image/png",
});
});
it("putScreenshotBlob uploads with expected options and returns URL", async () => {
process.env.SCREENSHOT_TTL_SECONDS = "10";
const { put } = await import("@vercel/blob");
(put as unknown as import("vitest").Mock).mockClear();
const url = await putScreenshotBlob(
"example.com",
1200,
630,
Buffer.from([1]),
);
expect(url).toBe("https://blob/put.png");
const calls = (put as unknown as import("vitest").Mock).mock.calls;
const call = calls[calls.length - 1];
expect(call?.[0]).toMatch(/^screenshots\//);
expect(call?.[0]).toMatch(/\/1200x630\.png$/);
expect(call?.[2]).toMatchObject({
access: "public",
contentType: "image/png",
});
});
it("headScreenshotBlob falls back to previous bucket on miss", async () => {
process.env.SCREENSHOT_TTL_SECONDS = "10";
const base = 1_000_000_000_000;
const realNow = Date.now;
Date.now = () => base;
void getScreenshotBucket();
Date.now = () => base + 1_000;
const { head } = await import("@vercel/blob");
(head as unknown as import("vitest").Mock).mockRejectedValueOnce(
new Error("current missing"),
);
(head as unknown as import("vitest").Mock).mockResolvedValueOnce({
url: "https://blob/fallback.png",
});
const url = await headScreenshotBlob("example.com", 1200, 630);
expect(url).toBe("https://blob/fallback.png");
Date.now = realNow;
});
it("headScreenshotBlob returns URL on success and null when both buckets miss", async () => {
const { head } = await import("@vercel/blob");
(head as unknown as import("vitest").Mock).mockResolvedValueOnce({
url: "https://blob/existing-screenshot.png",
});
const url = await headScreenshotBlob("example.com", 1200, 630);
expect(url).toBe("https://blob/existing-screenshot.png");
// Miss current and previous
(head as unknown as import("vitest").Mock).mockRejectedValueOnce(
new Error("miss-current"),
);
(head as unknown as import("vitest").Mock).mockRejectedValueOnce(
new Error("miss-prev"),
);
const none = await headScreenshotBlob("example.com", 1200, 630);
expect(none).toBeNull();
});
});

View File

@@ -5,42 +5,76 @@ import { head, put } from "@vercel/blob";
const ONE_WEEK_SECONDS = 7 * 24 * 60 * 60;
function toPositiveInt(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
export function getFaviconTtlSeconds(): number {
return toPositiveInt(process.env.FAVICON_TTL_SECONDS, ONE_WEEK_SECONDS);
}
export function getScreenshotTtlSeconds(): number {
return toPositiveInt(process.env.SCREENSHOT_TTL_SECONDS, ONE_WEEK_SECONDS);
}
function getBucket(nowMs: number, ttlSeconds: number): number {
return Math.floor(nowMs / (ttlSeconds * 1000));
}
export function getFaviconBucket(nowMs = Date.now()): number {
return getBucket(nowMs, getFaviconTtlSeconds());
}
export function getScreenshotBucket(nowMs = Date.now()): number {
return getBucket(nowMs, getScreenshotTtlSeconds());
}
function getSigningSecret(): string {
if (
process.env.NODE_ENV === "production" &&
!process.env.FAVICON_BLOB_SIGNING_SECRET
!process.env.BLOB_SIGNING_SECRET
) {
throw new Error("FAVICON_BLOB_SIGNING_SECRET required in production");
throw new Error("BLOB_SIGNING_SECRET required in production");
}
const secret =
process.env.FAVICON_BLOB_SIGNING_SECRET ||
process.env.BLOB_SIGNING_SECRET ||
process.env.BLOB_READ_WRITE_TOKEN ||
"dev-favicon-secret";
return secret;
}
export function computeFaviconBlobPath(domain: string, size: number): string {
const input = `${domain}:${size}`;
const bucket = getFaviconBucket();
const input = `${bucket}:${domain}:${size}`;
const secret = getSigningSecret();
const digest = createHmac("sha256", secret).update(input).digest("hex");
// Avoid leaking domain; path is deterministic but unpredictable without secret
return `favicons/${digest}/${size}.png`;
return `favicons/${bucket}/${digest}/${size}.png`;
}
export async function headFaviconBlob(
domain: string,
size: number,
): Promise<string | null> {
const pathname = computeFaviconBlobPath(domain, size);
try {
const res = await head(pathname, {
token: process.env.BLOB_READ_WRITE_TOKEN,
});
return res?.url ?? null;
} catch {
return null;
const current = getFaviconBucket();
const candidates = [current, current - 1];
for (const bucket of candidates) {
const input = `${bucket}:${domain}:${size}`;
const secret = getSigningSecret();
const digest = createHmac("sha256", secret).update(input).digest("hex");
const pathname = `favicons/${bucket}/${digest}/${size}.png`;
try {
const res = await head(pathname, {
token: process.env.BLOB_READ_WRITE_TOKEN,
});
if (res?.url) return res.url;
} catch {
// try next candidate
}
}
return null;
}
export async function putFaviconBlob(
@@ -48,11 +82,73 @@ export async function putFaviconBlob(
size: number,
png: Buffer,
): Promise<string> {
const pathname = computeFaviconBlobPath(domain, size);
const bucket = getFaviconBucket();
const input = `${bucket}:${domain}:${size}`;
const secret = getSigningSecret();
const digest = createHmac("sha256", secret).update(input).digest("hex");
const pathname = `favicons/${bucket}/${digest}/${size}.png`;
const res = await put(pathname, png, {
access: "public",
contentType: "image/png",
cacheControlMaxAge: ONE_WEEK_SECONDS,
cacheControlMaxAge: getFaviconTtlSeconds(),
token: process.env.BLOB_READ_WRITE_TOKEN,
});
return res.url;
}
// Screenshot blob helpers (same HMAC hashing approach as favicons)
export function computeScreenshotBlobPath(
domain: string,
width: number,
height: number,
): string {
const bucket = getScreenshotBucket();
const input = `${bucket}:${domain}:${width}x${height}`;
const secret = getSigningSecret();
const digest = createHmac("sha256", secret).update(input).digest("hex");
return `screenshots/${bucket}/${digest}/${width}x${height}.png`;
}
export async function headScreenshotBlob(
domain: string,
width: number,
height: number,
): Promise<string | null> {
const current = getScreenshotBucket();
const candidates = [current, current - 1];
for (const bucket of candidates) {
const input = `${bucket}:${domain}:${width}x${height}`;
const secret = getSigningSecret();
const digest = createHmac("sha256", secret).update(input).digest("hex");
const pathname = `screenshots/${bucket}/${digest}/${width}x${height}.png`;
try {
const res = await head(pathname, {
token: process.env.BLOB_READ_WRITE_TOKEN,
});
if (res?.url) return res.url;
} catch {
// try next candidate
}
}
return null;
}
export async function putScreenshotBlob(
domain: string,
width: number,
height: number,
png: Buffer,
): Promise<string> {
const bucket = getScreenshotBucket();
const input = `${bucket}:${domain}:${width}x${height}`;
const secret = getSigningSecret();
const digest = createHmac("sha256", secret).update(input).digest("hex");
const pathname = `screenshots/${bucket}/${digest}/${width}x${height}.png`;
const res = await put(pathname, png, {
access: "public",
contentType: "image/png",
cacheControlMaxAge: getScreenshotTtlSeconds(),
token: process.env.BLOB_READ_WRITE_TOKEN,
});
return res.url;

94
lib/image.ts Normal file
View File

@@ -0,0 +1,94 @@
import "server-only";
import sharp from "sharp";
function isIcoBuffer(buf: Buffer): boolean {
return (
buf.length >= 4 &&
buf[0] === 0x00 &&
buf[1] === 0x00 &&
buf[2] === 0x01 &&
buf[3] === 0x00
);
}
export async function convertBufferToPngCover(
input: Buffer,
width: number,
height: number,
contentTypeHint?: string | null,
): Promise<Buffer | null> {
try {
const img = sharp(input, { failOn: "none" });
const pipeline = img
.resize(width, height, { fit: "cover" })
.png({ compressionLevel: 9 });
return await pipeline.toBuffer();
} catch {
// ignore and try ICO-specific decode if it looks like ICO
}
if (isIcoBuffer(input) || (contentTypeHint && /icon/.test(contentTypeHint))) {
try {
type IcoFrame = {
width: number;
height: number;
buffer?: ArrayBuffer;
data?: ArrayBuffer;
};
const mod = (await import("icojs")) as unknown as {
parse: (buf: ArrayBuffer, outputType?: string) => Promise<IcoFrame[]>;
};
const arr = (input.buffer as ArrayBuffer).slice(
input.byteOffset,
input.byteOffset + input.byteLength,
) as ArrayBuffer;
const frames = await mod.parse(arr as ArrayBuffer, "image/png");
if (Array.isArray(frames) && frames.length > 0) {
let chosen: IcoFrame = frames[0];
chosen = frames.reduce((best: IcoFrame, cur: IcoFrame) => {
const bw = Number(best?.width ?? 0);
const bh = Number(best?.height ?? 0);
const cw = Number(cur?.width ?? 0);
const ch = Number(cur?.height ?? 0);
// Manhattan distance to target rectangle for better rectangular fit
const bDelta = Math.abs(bw - width) + Math.abs(bh - height);
const cDelta = Math.abs(cw - width) + Math.abs(ch - height);
return cDelta < bDelta ? cur : best;
}, chosen);
const arrBuf: ArrayBuffer | undefined = chosen.buffer ?? chosen.data;
if (arrBuf) {
const pngBuf = Buffer.from(arrBuf);
return await sharp(pngBuf)
.resize(width, height, { fit: "cover" })
.png({ compressionLevel: 9 })
.toBuffer();
}
}
} catch {
// Fall through to null
}
}
return null;
}
export async function convertBufferToSquarePng(
input: Buffer,
size: number,
contentTypeHint?: string | null,
): Promise<Buffer | null> {
return convertBufferToPngCover(input, size, size, contentTypeHint);
}
export async function optimizePngCover(
png: Buffer,
width: number,
height: number,
): Promise<Buffer> {
return await sharp(png)
.resize(width, height, { fit: "cover" })
.png({ compressionLevel: 9 })
.toBuffer();
}

View File

@@ -16,7 +16,9 @@ const nextConfig: NextConfig = {
dynamic: 0, // disable client-side router cache for dynamic pages
},
},
serverExternalPackages: ["@sparticuz/chromium", "puppeteer-core"],
images: {
unoptimized: true,
remotePatterns: [
{
protocol: "https",

View File

@@ -25,6 +25,7 @@
},
"dependencies": {
"@posthog/nextjs-config": "^1.3.1",
"@sparticuz/chromium": "^138.0.2",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@trpc/client": "^11.6.0",
@@ -47,6 +48,7 @@
"next-themes": "^0.4.6",
"posthog-js": "^1.268.6",
"posthog-node": "^5.9.1",
"puppeteer-core": "^24.22.3",
"radix-ui": "^1.4.3",
"rdapper": "^0.1.0",
"react": "19.1.1",
@@ -77,6 +79,7 @@
"@vitest/ui": "3.2.4",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"jsdom": "^27.0.0",
"puppeteer": "^24.22.3",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.2",

663
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,4 +3,5 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide'
- core-js
- esbuild
- puppeteer
- sharp

View File

@@ -3,6 +3,7 @@ import { getOrCreateFaviconBlobUrl } from "../services/favicon";
import { probeHeaders } from "../services/headers";
import { detectHosting } from "../services/hosting";
import { getRegistration } from "../services/registration";
import { getOrCreateScreenshotBlobUrl } from "../services/screenshot";
import { getCertificates } from "../services/tls";
import { router } from "../trpc";
import { createDomainProcedure } from "./domain-procedure";
@@ -23,4 +24,8 @@ export const domainRouter = router({
getOrCreateFaviconBlobUrl,
"Favicon fetch failed",
),
screenshot: createDomainProcedure(
getOrCreateScreenshotBlobUrl,
"Screenshot capture failed",
),
});

View File

@@ -0,0 +1,2 @@
export const USER_AGENT =
process.env.HOOT_USER_AGENT || "hoot.sh/0.1 (+https://hoot.sh)";

View File

@@ -1,3 +1,4 @@
import { USER_AGENT } from "./constants";
export type DnsRecordType = "A" | "AAAA" | "MX" | "TXT" | "NS";
export type DohProviderKey = "cloudflare" | "google";
@@ -10,7 +11,7 @@ export type DohProvider = {
const DEFAULT_HEADERS: Record<string, string> = {
accept: "application/dns-json",
"user-agent": "hoot.sh/0.1 (+https://hoot.sh)",
"user-agent": USER_AGENT,
};
export const DOH_PROVIDERS: DohProvider[] = [

View File

@@ -1,22 +1,13 @@
import sharp from "sharp";
import { captureServer } from "@/lib/analytics/server";
import { headFaviconBlob, putFaviconBlob } from "@/lib/blob";
import { convertBufferToSquarePng } from "@/lib/image";
import { USER_AGENT } from "./constants";
const DEFAULT_SIZE = 32;
const REQUEST_TIMEOUT_MS = 1500; // per each method
// Legacy Redis-based caching removed; Blob is now the canonical store
function isIcoBuffer(buf: Buffer): boolean {
return (
buf.length >= 4 &&
buf[0] === 0x00 &&
buf[1] === 0x00 &&
buf[2] === 0x01 &&
buf[3] === 0x00
);
}
async function fetchWithTimeout(
url: string,
init?: RequestInit,
@@ -28,7 +19,7 @@ async function fetchWithTimeout(
redirect: "follow",
headers: {
Accept: "image/avif,image/webp,image/png,image/*;q=0.9,*/*;q=0.8",
"User-Agent": "hoot.sh/0.1 (+https://hoot.sh)",
"User-Agent": USER_AGENT,
},
signal: controller.signal,
...init,
@@ -44,59 +35,7 @@ async function convertToPng(
contentType: string | null,
size: number,
): Promise<Buffer | null> {
try {
const img = sharp(input, { failOn: "none" });
const pipeline = img
.resize(size, size, { fit: "cover" })
.png({ compressionLevel: 9 });
return await pipeline.toBuffer();
} catch {
// ignore and try ICO-specific decode if it looks like ICO
}
if (isIcoBuffer(input) || (contentType && /icon/.test(contentType))) {
try {
type IcoFrame = {
width: number;
height: number;
buffer?: ArrayBuffer;
data?: ArrayBuffer;
};
const mod = (await import("icojs")) as unknown as {
parse: (buf: ArrayBuffer, outputType?: string) => Promise<IcoFrame[]>;
};
const arr = (input.buffer as ArrayBuffer).slice(
input.byteOffset,
input.byteOffset + input.byteLength,
) as ArrayBuffer;
const frames = await mod.parse(arr as ArrayBuffer, "image/png");
if (Array.isArray(frames) && frames.length > 0) {
let chosen: IcoFrame = frames[0];
chosen = frames.reduce((best: IcoFrame, cur: IcoFrame) => {
const bw = Number(best?.width ?? 0);
const cw = Number(cur?.width ?? 0);
const bh = Number(best?.height ?? 0);
const ch = Number(cur?.height ?? 0);
const bDelta = Math.abs(Math.max(bw, bh) - size);
const cDelta = Math.abs(Math.max(cw, ch) - size);
return cDelta < bDelta ? cur : best;
}, chosen);
const arrBuf: ArrayBuffer | undefined = chosen.buffer ?? chosen.data;
if (arrBuf) {
const pngBuf = Buffer.from(arrBuf);
return await sharp(pngBuf)
.resize(size, size, { fit: "cover" })
.png({ compressionLevel: 9 })
.toBuffer();
}
}
} catch {
// Fall through to null
}
}
return null;
return convertBufferToSquarePng(input, size, contentType);
}
function buildSources(domain: string): string[] {

View File

@@ -0,0 +1,61 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const blobMock = vi.hoisted(() => ({
headScreenshotBlob: vi.fn(),
putScreenshotBlob: vi.fn(async () => "blob://stored-screenshot"),
}));
vi.mock("@/lib/blob", () => blobMock);
// Mock puppeteer environments
const pageMock = {
setViewport: vi.fn(async () => undefined),
setUserAgent: vi.fn(async () => undefined),
goto: vi.fn(async () => undefined),
screenshot: vi.fn(async () => Buffer.from([1, 2, 3])),
};
const browserMock = {
newPage: vi.fn(async () => pageMock),
close: vi.fn(async () => undefined),
};
vi.mock("puppeteer", () => ({
launch: vi.fn(async () => browserMock),
}));
vi.mock("puppeteer-core", () => ({
launch: vi.fn(async () => browserMock),
}));
// Optimize does a simple pass-through for test speed
vi.mock("@/lib/image", () => ({
optimizePngCover: vi.fn(async (b: Buffer) => b),
}));
import { getOrCreateScreenshotBlobUrl } from "./screenshot";
beforeEach(() => {
process.env.VERCEL_ENV = ""; // force local puppeteer path in tests
});
afterEach(() => {
vi.restoreAllMocks();
blobMock.headScreenshotBlob.mockReset();
blobMock.putScreenshotBlob.mockReset();
});
describe("getOrCreateScreenshotBlobUrl", () => {
it("returns existing blob url when present", async () => {
blobMock.headScreenshotBlob.mockResolvedValueOnce("blob://existing");
const out = await getOrCreateScreenshotBlobUrl("example.com");
expect(out.url).toBe("blob://existing");
expect(blobMock.putScreenshotBlob).not.toHaveBeenCalled();
});
it("captures, uploads and returns url when not cached", async () => {
blobMock.headScreenshotBlob.mockResolvedValueOnce(null);
const out = await getOrCreateScreenshotBlobUrl("example.com");
expect(out.url).toBe("blob://stored-screenshot");
expect(blobMock.putScreenshotBlob).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,230 @@
import { captureServer } from "@/lib/analytics/server";
import { headScreenshotBlob, putScreenshotBlob } from "@/lib/blob";
import { optimizePngCover } from "@/lib/image";
import { USER_AGENT } from "./constants";
const VIEWPORT_WIDTH = 1200;
const VIEWPORT_HEIGHT = 630;
const NAV_TIMEOUT_MS = 8000;
function buildHomepageUrls(domain: string): string[] {
return [`https://${domain}`, `http://${domain}`];
}
export async function getOrCreateScreenshotBlobUrl(
domain: string,
opts?: { distinctId?: string },
): Promise<{ url: string | null }> {
const startedAt = Date.now();
// 1) Check existing blob
try {
const existing = await headScreenshotBlob(
domain,
VIEWPORT_WIDTH,
VIEWPORT_HEIGHT,
);
if (existing) {
await captureServer(
"screenshot_capture",
{
domain,
width: VIEWPORT_WIDTH,
height: VIEWPORT_HEIGHT,
source: "blob",
duration_ms: Date.now() - startedAt,
outcome: "ok",
cache: "hit_blob",
},
opts?.distinctId,
);
return { url: existing };
}
} catch {
// ignore and proceed
}
// 2) Attempt to capture
let browser: import("puppeteer-core").Browser | null = null;
try {
const isVercel = process.env.VERCEL === "1";
const isLinux = process.platform === "linux";
const preferChromium = isLinux || isVercel;
type LaunchFn = (
options?: Record<string, unknown>,
) => Promise<import("puppeteer-core").Browser>;
let puppeteerLaunch: LaunchFn = async () => {
throw new Error("puppeteer launcher not configured");
};
let launchOptions: Record<string, unknown> = { headless: true };
let launcherMode: "chromium" | "puppeteer" = preferChromium
? "chromium"
: "puppeteer";
async function setupChromium() {
const chromium = (await import("@sparticuz/chromium")).default;
const core = await import("puppeteer-core");
puppeteerLaunch = core.launch as unknown as LaunchFn;
launchOptions = {
...launchOptions,
args: chromium.args,
executablePath: await chromium.executablePath(),
};
console.debug("[screenshot] using chromium", {
executablePath: (launchOptions as { executablePath?: unknown })
.executablePath,
});
}
async function setupPuppeteer() {
const full = await import("puppeteer");
puppeteerLaunch = (full as unknown as { launch: LaunchFn }).launch;
const path = process.env.PUPPETEER_EXECUTABLE_PATH;
launchOptions = {
...launchOptions,
...(path ? { executablePath: path } : {}),
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
};
console.debug("[screenshot] using puppeteer", {
executablePath: path || null,
});
}
// First attempt based on platform preference
try {
if (launcherMode === "chromium") await setupChromium();
else await setupPuppeteer();
// Try launch
console.debug("[screenshot] launching browser", { mode: launcherMode });
browser = await puppeteerLaunch(launchOptions);
} catch (firstErr) {
console.warn("[screenshot] first launch attempt failed", {
mode: launcherMode,
error: (firstErr as Error)?.message,
});
// Flip mode and retry once
launcherMode = launcherMode === "chromium" ? "puppeteer" : "chromium";
try {
if (launcherMode === "chromium") await setupChromium();
else await setupPuppeteer();
console.debug("[screenshot] retry launching browser", {
mode: launcherMode,
});
browser = await puppeteerLaunch(launchOptions);
} catch (secondErr) {
console.error("[screenshot] both launch attempts failed", {
first_error: (firstErr as Error)?.message,
second_error: (secondErr as Error)?.message,
});
throw secondErr;
}
}
console.debug("[screenshot] browser launched", { mode: launcherMode });
const tryUrls = buildHomepageUrls(domain);
for (const url of tryUrls) {
try {
const page = await browser.newPage();
await page.setViewport({
width: VIEWPORT_WIDTH,
height: VIEWPORT_HEIGHT,
deviceScaleFactor: 1,
});
await page.setUserAgent(USER_AGENT);
console.debug("[screenshot] navigating", { url });
await page.goto(url, {
waitUntil: "networkidle2",
timeout: NAV_TIMEOUT_MS,
});
console.debug("[screenshot] navigated", { url });
const rawPng: Buffer = (await page.screenshot({
type: "png",
fullPage: false,
})) as Buffer;
const png = await optimizePngCover(
rawPng,
VIEWPORT_WIDTH,
VIEWPORT_HEIGHT,
);
if (png && png.length > 0) {
const storedUrl = await putScreenshotBlob(
domain,
VIEWPORT_WIDTH,
VIEWPORT_HEIGHT,
png,
);
console.info("[screenshot] stored blob", { url: storedUrl });
await captureServer(
"screenshot_capture",
{
domain,
width: VIEWPORT_WIDTH,
height: VIEWPORT_HEIGHT,
source: url.startsWith("https://")
? "direct_https"
: "direct_http",
duration_ms: Date.now() - startedAt,
outcome: "ok",
cache: "store_blob",
},
opts?.distinctId,
);
return { url: storedUrl };
}
} catch (err) {
// try next URL
console.warn("[screenshot] attempt failed", {
url,
error: (err as Error)?.message,
});
}
}
} catch (err) {
// fallthrough to not_found
console.error("[screenshot] capture failed", {
domain,
error: (err as Error)?.message,
});
} finally {
if (browser) {
try {
await browser.close();
} catch {}
}
}
await captureServer(
"screenshot_capture",
{
domain,
width: VIEWPORT_WIDTH,
height: VIEWPORT_HEIGHT,
duration_ms: Date.now() - startedAt,
outcome: "not_found",
cache: "miss",
},
opts?.distinctId,
);
console.warn("[screenshot] returning null", { domain });
return { url: null };
}

View File

@@ -2,7 +2,14 @@
"$schema": "https://openapi.vercel.sh/vercel.json",
"build": {
"env": {
"ENABLE_EXPERIMENTAL_COREPACK": "1"
"ENABLE_EXPERIMENTAL_COREPACK": "1",
"PUPPETEER_SKIP_DOWNLOAD": "1"
}
}
},
"crons": [
{
"path": "/api/cron/blob-prune",
"schedule": "0 3 * * *"
}
]
}