diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 6268a82f..b9c01b47 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { createClient } from "@/lib/supabase/server"; import { ScoreBadge } from "@/components/score-badge"; import { FontSparkline } from "@/components/font-sparkline"; +import { ProjectLogo } from "@/components/project-logo"; import { backfillProjectLogo } from "@/app/actions/createProject"; import { getOrCreateDefaultOrg, isOrgWideRole, listUserOrgs, missingOrgSchema } from "@/lib/orgs"; import { listOrgTeam } from "@/app/actions/org-members"; @@ -196,6 +197,7 @@ export default async function DashboardPage({
{p.name}
@@ -315,39 +317,6 @@ export default async function DashboardPage({ ); } -function ProjectLogo({ - url, - name, -}: { - url: string | null; - name: string; -}) { - const letter = (name || "?").trim().charAt(0).toUpperCase(); - // 40px square; rounded corners; one-letter fallback when the site - // either has no detectable logo or backfill hasn't run yet. - if (!url) { - return ( -
- {letter} -
- ); - } - return ( - // eslint-disable-next-line @next/next/no-img-element - - ); -} - type LatestPost = { url: string; publishedAt: string }; type TrafficPoint = { day: string; count: number }; diff --git a/app/actions/createProject.ts b/app/actions/createProject.ts index 4f798a4b..ef99b651 100644 --- a/app/actions/createProject.ts +++ b/app/actions/createProject.ts @@ -101,6 +101,40 @@ export async function backfillProjectLogo( } } +// Re-run logo discovery for a project whose stored logo is broken (the +// dashboard's onError triggers this). Unlike backfillProjectLogo it +// overwrites an existing logo_url, and clears it when nothing valid is found so +// the tile falls back to a letter avatar instead of a broken image. +export async function refetchProjectLogo( + projectId: string, +): Promise<{ ok: boolean }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false }; + + const { data: project } = await supabase + .from("projects") + .select("id, url") + .eq("id", projectId) + .maybeSingle(); + if (!project?.url) return { ok: false }; + + try { + const logoUrl = await discoverLogoUrl(project.url); + await serviceClient() + .from("projects") + .update({ logo_url: logoUrl }) + .eq("id", projectId); + revalidatePath("/dashboard"); + return { ok: true }; + } catch (err) { + console.warn("[discoverLogo] refetch failed for", projectId, err); + return { ok: false }; + } +} + export async function updateSchedule(input: { projectId: string; schedule: "off" | "daily" | "weekly" | "monthly"; diff --git a/components/project-logo.tsx b/components/project-logo.tsx new file mode 100644 index 00000000..b3542e9f --- /dev/null +++ b/components/project-logo.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRef, useState } from "react"; +import { refetchProjectLogo } from "@/app/actions/createProject"; + +// Project tile logo. Shows the discovered logo; on a broken image it falls back +// to a letter avatar immediately AND fires a one-shot re-discovery so a stale / +// 404'ing logo_url self-heals on the next render. +export function ProjectLogo({ + url, + name, + projectId, +}: { + url: string | null; + name: string; + projectId?: string; +}) { + const letter = (name || "?").trim().charAt(0).toUpperCase(); + const [broken, setBroken] = useState(false); + const refetched = useRef(false); + + if (!url || broken) { + return ( +
+ {letter} +
+ ); + } + + return ( + // eslint-disable-next-line @next/next/no-img-element + { + setBroken(true); + // Re-discover once; the server clears/updates logo_url so the next load + // shows a working logo (or a clean letter avatar). + if (projectId && !refetched.current) { + refetched.current = true; + void refetchProjectLogo(projectId); + } + }} + className="h-10 w-10 shrink-0 rounded-md border border-[var(--color-border)] bg-white object-contain p-1" + /> + ); +} diff --git a/lib/discoverLogo.ts b/lib/discoverLogo.ts index 0c96d003..aa6b7d48 100644 --- a/lib/discoverLogo.ts +++ b/lib/discoverLogo.ts @@ -1,23 +1,31 @@ import * as cheerio from "cheerio"; -// Walk a page's and pick the best-looking logo URL. Returns -// null if we can't find anything plausible (offline, parse error, -// site explicitly has no icons). +// Walk a page's (and its web-app manifest) and pick the best logo URL +// that ACTUALLY RESOLVES to an image. Returns null if nothing plausible loads +// (offline, parse error, site has no usable icons) — the dashboard then shows a +// letter avatar instead of a broken . // -// Preference order — this is what the dashboard ends up showing: -// 1. apple-touch-icon (largest size attribute wins) -// 2. with type=image/svg+xml -// 3. with the largest size attribute -// 4. og:image (good for sites that don't ship a proper favicon set) -// 5. twitter:image -// 6. /favicon.ico — last-ditch, almost every site has this +// Preference order: +// 1. web-app-manifest icons (largest; often the real brand logo) +// 2. apple-touch-icon (largest size wins) +// 3. svg, then largest raster; mask-icon +// 4. og:image / twitter:image (sites with no proper favicon set) +// 5. /favicon.ico — last-ditch // -// We trust HTTP redirects on the page fetch (so http://foo -> https://foo -// works) but cap response time at FETCH_TIMEOUT_MS so a slow site can't -// block a project create or dashboard render. +// Every candidate is verified with a real request (2xx + image content-type) +// before we accept it, so we never persist a 404'ing URL. We trust redirects +// on the page fetch but cap response time so a slow site can't block a project +// create or dashboard render. const FETCH_TIMEOUT_MS = 4000; +const VALIDATE_TIMEOUT_MS = 3000; const MAX_BYTES = 1_000_000; // 1 MB of HTML is way more than enough for +const MAX_VALIDATIONS = 6; // cap image HEAD/GET checks so we stay snappy + +const UA = + "Mozilla/5.0 (compatible; CrawlProofLogoBot/1.0; +https://crawlproof.com)"; + +const IMAGE_EXT_RE = /\.(png|jpe?g|svg|ico|webp|gif|avif)(\?|#|$)/i; export async function discoverLogoUrl(siteUrl: string): Promise { let parsed: URL; @@ -30,54 +38,134 @@ export async function discoverLogoUrl(siteUrl: string): Promise { const html = await fetchHead(siteUrl); if (!html) { - // Even without the HTML, /favicon.ico is a defensible guess. - return `${parsed.origin}/favicon.ico`; + // Even without the HTML, /favicon.ico is a defensible guess — if it loads. + const ico = `${parsed.origin}/favicon.ico`; + return (await isImageUrl(ico)) ? ico : null; } const $ = cheerio.load(html); + // Respect for resolving relative icon URLs. + const baseHref = $("base[href]").attr("href"); + const base = baseHref ? safeUrl(baseHref, parsed) ?? parsed : parsed; const candidates: { url: string; weight: number }[] = []; + const add = (href: string | undefined, weight: number) => { + if (!href) return; + const abs = absolutize(href, base); + if (abs) candidates.push({ url: abs, weight }); + }; $('link[rel*="apple-touch-icon" i]').each((_i, el) => { - const href = $(el).attr("href"); - if (!href) return; - const size = parseSizes($(el).attr("sizes")); - // Apple icons are usually the prettiest variant; rank by size. - candidates.push({ url: href, weight: 1000 + size }); + add($(el).attr("href"), 900 + parseSizes($(el).attr("sizes"))); }); $('link[rel*="icon" i]').each((_i, el) => { const $el = $(el); const rel = ($el.attr("rel") ?? "").toLowerCase(); - if (rel.includes("apple-touch-icon")) return; // already collected above - const href = $el.attr("href"); - if (!href) return; + if (rel.includes("apple-touch-icon")) return; // collected above const type = ($el.attr("type") ?? "").toLowerCase(); const size = parseSizes($el.attr("sizes")); // SVG icons scale to any DOM size — prefer them over raster. - const base = type === "image/svg+xml" ? 800 : 400; - candidates.push({ url: href, weight: base + size }); + const weight = type === "image/svg+xml" ? 800 + size : 400 + size; + add($el.attr("href"), weight); }); - const og = $('meta[property="og:image"]').attr("content"); - if (og) candidates.push({ url: og, weight: 200 }); - const tw = $('meta[name="twitter:image"]').attr("content"); - if (tw) candidates.push({ url: tw, weight: 150 }); + // Safari pinned-tab / mask icon — usually a clean monochrome SVG logo. + $('link[rel*="mask-icon" i]').each((_i, el) => { + add($(el).attr("href"), 700); + }); + + add($('meta[property="og:image"]').attr("content"), 200); + add($('meta[name="twitter:image"]').attr("content"), 150); + + // Web app manifest icons — often the highest-quality brand mark. + const manifestHref = $('link[rel="manifest" i]').attr("href"); + if (manifestHref) { + const manifestUrl = absolutize(manifestHref, base); + if (manifestUrl) { + for (const icon of await fetchManifestIcons(manifestUrl)) { + // Rank just below apple-touch so a big manifest icon can win. + add(icon.src, 950 + icon.size); + } + } + } - candidates.push({ url: "/favicon.ico", weight: 1 }); + add(`${parsed.origin}/favicon.ico`, 1); - candidates.sort((a, b) => b.weight - a.weight); + // Highest weight first, de-duped, then return the first that actually loads. + const seen = new Set(); + const ordered = candidates + .sort((a, b) => b.weight - a.weight) + .filter((c) => (seen.has(c.url) ? false : (seen.add(c.url), true))); - for (const c of candidates) { - const abs = absolutize(c.url, parsed); - if (abs) return abs; + let checked = 0; + for (const c of ordered) { + if (checked >= MAX_VALIDATIONS) break; + checked++; + if (await isImageUrl(c.url)) return c.url; } return null; } +// Resolve a manifest URL to its icon list ({src, size}), largest first-ish. +async function fetchManifestIcons( + manifestUrl: string, +): Promise<{ src: string; size: number }[]> { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), VALIDATE_TIMEOUT_MS); + try { + const res = await fetch(manifestUrl, { + redirect: "follow", + signal: ctrl.signal, + headers: { "User-Agent": UA, Accept: "application/manifest+json,application/json" }, + }); + if (!res.ok) return []; + const json = (await res.json()) as { + icons?: Array<{ src?: string; sizes?: string }>; + }; + const manifestBase = new URL(manifestUrl); + const out: { src: string; size: number }[] = []; + for (const icon of json.icons ?? []) { + const abs = icon.src ? safeUrl(icon.src, manifestBase) : null; + if (abs) out.push({ src: abs.toString(), size: parseSizes(icon.sizes) }); + } + return out; + } catch { + return []; + } finally { + clearTimeout(timer); + } +} + +// Verify a URL responds 2xx with an image content-type (or an image extension +// when a server sends a generic type). Cancels the body so we don't download +// the whole image. +async function isImageUrl(url: string): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), VALIDATE_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: "GET", + redirect: "follow", + signal: ctrl.signal, + headers: { "User-Agent": UA, Accept: "image/*,*/*" }, + }); + void res.body?.cancel().catch(() => {}); + if (!res.ok) return false; + const ct = (res.headers.get("content-type") ?? "").toLowerCase(); + if (ct.startsWith("image/")) return true; + // Some CDNs mislabel; trust a clear image extension on a 2xx. + return IMAGE_EXT_RE.test(new URL(res.url || url).pathname); + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + function parseSizes(attr: string | undefined): number { if (!attr) return 0; - // sizes="180x180" or sizes="32x32 16x16" — pick the largest dimension. + if (/any/i.test(attr)) return 512; // scalable ("any") — treat as large let max = 0; for (const tok of attr.split(/\s+/)) { const m = tok.match(/^(\d+)x(\d+)$/i); @@ -89,14 +177,18 @@ function parseSizes(attr: string | undefined): number { return max; } -function absolutize(href: string, base: URL): string | null { +function safeUrl(href: string, base: URL): URL | null { try { - return new URL(href, base).toString(); + return new URL(href, base); } catch { return null; } } +function absolutize(href: string, base: URL): string | null { + return safeUrl(href, base)?.toString() ?? null; +} + async function fetchHead(url: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); diff --git a/tests/discover-logo.test.ts b/tests/discover-logo.test.ts new file mode 100644 index 00000000..1482119f --- /dev/null +++ b/tests/discover-logo.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { discoverLogoUrl } from "@/lib/discoverLogo"; + +const PAGE = "https://example.com/"; + +// Build a fake fetch keyed by URL. `images` maps an absolute image URL to the +// status/content-type it should return; anything else 404s. +function stubFetch(html: string, images: Record) { + vi.stubGlobal("fetch", async (input: unknown) => { + const url = String(input); + if (url === PAGE) { + return { + ok: true, + status: 200, + url, + headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null) }, + body: null, // fetchHead falls back to res.text() + text: async () => html, + } as unknown as Response; + } + const img = images[url]; + if (img) { + return { + ok: img.status < 400, + status: img.status, + url, + headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? img.ct ?? "image/png" : null) }, + body: { cancel: async () => {} }, + } as unknown as Response; + } + return { ok: false, status: 404, url, headers: { get: () => null }, body: { cancel: async () => {} } } as unknown as Response; + }); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("discoverLogoUrl", () => { + it("skips a broken candidate and returns the next one that actually loads", async () => { + const html = ` + + + `; + // Apple icon (highest weight) 404s; favicon.ico is valid. + stubFetch(html, { + "https://example.com/apple.png": { status: 404 }, + "https://example.com/favicon.ico": { status: 200, ct: "image/x-icon" }, + }); + expect(await discoverLogoUrl(PAGE)).toBe("https://example.com/favicon.ico"); + }); + + it("prefers the highest-weight icon when it loads", async () => { + const html = ` + + + `; + stubFetch(html, { + "https://example.com/apple.png": { status: 200, ct: "image/png" }, + "https://example.com/favicon.ico": { status: 200, ct: "image/x-icon" }, + }); + expect(await discoverLogoUrl(PAGE)).toBe("https://example.com/apple.png"); + }); + + it("returns null when no candidate resolves to an image", async () => { + const html = ``; + stubFetch(html, {}); // everything 404s, including /favicon.ico + expect(await discoverLogoUrl(PAGE)).toBeNull(); + }); + + it("accepts an image served with a generic content-type by extension", async () => { + const html = ``; + stubFetch(html, { + "https://example.com/logo.svg": { status: 200, ct: "application/octet-stream" }, + "https://example.com/favicon.ico": { status: 404 }, + }); + expect(await discoverLogoUrl(PAGE)).toBe("https://example.com/logo.svg"); + }); +});