const MAX_HTML_BYTES = 512 * 1024 function metaContent(html: string, keys: string[]): string | null { for (const key of keys) { const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const before = new RegExp(`]+(?:property|name)=["']${escaped}["'][^>]+content=["']([^"']+)["']`, 'i') const after = new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${escaped}["']`, 'i') const match = before.exec(html) ?? after.exec(html) if (match?.[1]?.trim()) return match[1].trim() } return null } export interface LinkPreview { image: string | null title: string | null } const BLOCKED_HOST = /^(localhost|127\.|0\.0\.0\.0|::1|\[::1\]|169\.254\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|.*\.(local|internal|lan)$)/i export function isBlockedHost(hostname: string): boolean { return BLOCKED_HOST.test(hostname) } export async function fetchLinkPreview(url: string): Promise { try { const hostname = new URL(url).hostname if (isBlockedHost(hostname)) return { image: null, title: null } const res = await fetch(url, { signal: AbortSignal.timeout(5000), redirect: 'follow', headers: { 'user-agent': 'Mozilla/5.0 (compatible; 4Brett-LinkPreview/1.0)', 'accept': 'text/html,application/xhtml+xml', }, }) if (!res.ok) return { image: null, title: null } const contentType = res.headers.get('content-type') ?? '' if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) { return { image: null, title: null } } const html = (await res.text()).slice(0, MAX_HTML_BYTES) const base = res.url || url const image = metaContent(html, ['og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src']) const title = metaContent(html, ['og:title', 'twitter:title']) ?? /]*>([^<]+)<\/title>/i.exec(html)?.[1]?.trim() ?? null return { image: image ? new URL(image, base).href.slice(0, 2048) : null, title: title?.slice(0, 500) ?? null, } } catch { return { image: null, title: null } } }