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:
@@ -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=
|
||||
|
50
app/api/cron/blob-prune/route.test.ts
Normal file
50
app/api/cron/blob-prune/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
85
app/api/cron/blob-prune/route.ts
Normal file
85
app/api/cron/blob-prune/route.ts
Normal 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,
|
||||
});
|
||||
}
|
@@ -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
|
||||
|
93
components/domain/screenshot-tooltip.test.tsx
Normal file
93
components/domain/screenshot-tooltip.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
45
components/domain/screenshot-tooltip.tsx
Normal file
45
components/domain/screenshot-tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
93
components/domain/screenshot.test.tsx
Normal file
93
components/domain/screenshot.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
88
components/domain/screenshot.tsx
Normal file
88
components/domain/screenshot.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
|
106
lib/blob.test.ts
106
lib/blob.test.ts
@@ -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();
|
||||
});
|
||||
});
|
||||
|
126
lib/blob.ts
126
lib/blob.ts
@@ -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
94
lib/image.ts
Normal 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();
|
||||
}
|
@@ -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",
|
||||
|
@@ -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
663
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,5 @@ onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- core-js
|
||||
- esbuild
|
||||
- puppeteer
|
||||
- sharp
|
||||
|
@@ -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",
|
||||
),
|
||||
});
|
||||
|
2
server/services/constants.ts
Normal file
2
server/services/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const USER_AGENT =
|
||||
process.env.HOOT_USER_AGENT || "hoot.sh/0.1 (+https://hoot.sh)";
|
@@ -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[] = [
|
||||
|
@@ -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[] {
|
||||
|
61
server/services/screenshot.test.ts
Normal file
61
server/services/screenshot.test.ts
Normal 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();
|
||||
});
|
||||
});
|
230
server/services/screenshot.ts
Normal file
230
server/services/screenshot.ts
Normal 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 };
|
||||
}
|
11
vercel.json
11
vercel.json
@@ -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 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user