diff --git a/.env.example b/.env.example
index 73ab110..e83e6ab 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/app/api/cron/blob-prune/route.test.ts b/app/api/cron/blob-prune/route.test.ts
new file mode 100644
index 0000000..32e4d5f
--- /dev/null
+++ b/app/api/cron/blob-prune/route.test.ts
@@ -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);
+ });
+});
diff --git a/app/api/cron/blob-prune/route.ts b/app/api/cron/blob-prune/route.ts
new file mode 100644
index 0000000..108f4e8
--- /dev/null
+++ b/app/api/cron/blob-prune/route.ts
@@ -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,
+ });
+}
diff --git a/components/domain/domain-report-view.tsx b/components/domain/domain-report-view.tsx
index 6be3147..73306d9 100644
--- a/components/domain/domain-report-view.tsx
+++ b/components/domain/domain-report-view.tsx
@@ -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({
-
- captureClient("external_domain_link_clicked", { domain })
- }
- >
-
-
{domain}
-
-
+
+
+ captureClient("external_domain_link_clicked", { domain })
+ }
+ >
+
+ {domain}
+
+
+