Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 2 additions & 33 deletions app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -196,6 +197,7 @@ export default async function DashboardPage({
<ProjectLogo
url={(p as { logo_url: string | null }).logo_url}
name={p.name}
projectId={p.id}
/>
<div className="min-w-0">
<div className="truncate font-semibold">{p.name}</div>
Expand Down Expand Up @@ -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 (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-card)] text-sm font-semibold text-[var(--color-muted)]"
aria-hidden
>
{letter}
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
width={40}
height={40}
loading="lazy"
className="h-10 w-10 shrink-0 rounded-md border border-[var(--color-border)] bg-white object-contain p-1"
/>
);
}

type LatestPost = { url: string; publishedAt: string };
type TrafficPoint = { day: string; count: number };

Expand Down
34 changes: 34 additions & 0 deletions app/actions/createProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,40 @@ export async function backfillProjectLogo(
}
}

// Re-run logo discovery for a project whose stored logo is broken (the
// dashboard's <img> 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";
Expand Down
53 changes: 53 additions & 0 deletions components/project-logo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-card)] text-sm font-semibold text-[var(--color-muted)]"
aria-hidden
>
{letter}
</div>
);
}

return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
width={40}
height={40}
loading="lazy"
onError={() => {
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"
/>
);
}
166 changes: 129 additions & 37 deletions lib/discoverLogo.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import * as cheerio from "cheerio";

// Walk a page's <head> 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 <head> (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 <img>.
//
// Preference order — this is what the dashboard ends up showing:
// 1. apple-touch-icon (largest size attribute wins)
// 2. <link rel="icon"> with type=image/svg+xml
// 3. <link rel="icon"> 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. <link rel="icon"> 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 <head>
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<string | null> {
let parsed: URL;
Expand All @@ -30,54 +38,134 @@ export async function discoverLogoUrl(siteUrl: string): Promise<string | null> {

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 <base href> 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<string>();
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<boolean> {
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);
Expand All @@ -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<string | null> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
Expand Down
Loading
Loading