| @@ -1,15 +1,65 @@ |
| 1 | 1 | import { BoxIcon } from "lucide-react"; |
| 2 | +import { useState } from "react"; |
| 3 | +import { getDomain } from "tldts"; |
| 2 | 4 | |
| 3 | 5 | // --------------------------------------------------------------------------- |
| 4 | | −// SourceFavicon — renders a neutral local source icon. |
| 6 | +// SourceFavicon — renders the source site's own public favicon. |
| 5 | 7 | // Do not fetch third-party favicon services here; source URLs may be private. |
| 6 | 8 | // --------------------------------------------------------------------------- |
| 7 | 9 | |
| 8 | | −export function SourceFavicon({ size = 16 }: { url?: string; size?: number }) { |
| 10 | +const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/; |
| 11 | + |
| 12 | +const isIpHostname = (hostname: string): boolean => |
| 13 | + IPV4_PATTERN.test(hostname) || hostname.includes(":"); |
| 14 | + |
| 15 | +export function sourceFaviconUrl(url: string | undefined, size: number): string | null { |
| 16 | + if (!url) return null; |
| 17 | + if (!URL.canParse(url)) return null; |
| 18 | + |
| 19 | + const parsed = new URL(url); |
| 20 | + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; |
| 21 | + |
| 22 | + const hostname = parsed.hostname.toLowerCase(); |
| 23 | + if ( |
| 24 | + hostname === "localhost" || |
| 25 | + hostname.endsWith(".localhost") || |
| 26 | + hostname.endsWith(".local") || |
| 27 | + isIpHostname(hostname) |
| 28 | + ) { |
| 29 | + return null; |
| 30 | + } |
| 31 | + |
| 32 | + const domain = getDomain(hostname); |
| 33 | + if (!domain) return null; |
| 34 | + |
| 35 | + const favicon = new URL(`https://${domain}/favicon.ico`); |
| 36 | + favicon.searchParams.set("sz", String(size * 2)); |
| 37 | + return favicon.toString(); |
| 38 | +} |
| 39 | + |
| 40 | +export function SourceFavicon({ url, size = 16 }: { url?: string; size?: number }) { |
| 41 | + const [failed, setFailed] = useState(false); |
| 42 | + const src = failed ? null : sourceFaviconUrl(url, size); |
| 43 | + |
| 44 | + if (!src) { |
| 45 | + return ( |
| 46 | + <BoxIcon |
| 47 | + aria-hidden |
| 48 | + className="shrink-0 text-muted-foreground" |
| 49 | + style={{ width: size, height: size }} |
| 50 | + /> |
| 51 | + ); |
| 52 | + } |
| 53 | + |
| 9 | 54 | return ( |
| 10 | | − <BoxIcon |
| 11 | | − aria-hidden |
| 12 | | − className="shrink-0 text-muted-foreground" |
| 55 | + <img |
| 56 | + src={src} |
| 57 | + alt="" |
| 58 | + width={size} |
| 59 | + height={size} |
| 60 | + loading="lazy" |
| 61 | + onError={() => setFailed(true)} |
| 62 | + className="shrink-0 rounded-sm" |
| 13 | 63 | style={{ width: size, height: size }} |
| 14 | 64 | /> |
| 15 | 65 | ); |