// Edge function — fetch OpenGraph/meta tags for a URL and cache the result. // // POST /og-preview { "url": "..." } // // Returns: { url, title, description, imageUrl, siteName, ok } // // Caching: rows live in public.link_previews keyed by URL. Repeat calls // return the cached row without re-fetching (unless older than MAX_CACHE_AGE_MS). import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? ''; const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; const MAX_CACHE_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days const FETCH_TIMEOUT_MS = 6_000; const MAX_HTML_BYTES = 1_000_000; const corsHeaders = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', 'access-control-allow-methods': 'POST, OPTIONS', }; function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...corsHeaders }, }); } Deno.serve(async (req) => { if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }); if (req.method !== 'POST') return json({ error: 'method' }, 405); let body: { url?: string }; try { body = await req.json(); } catch { return json({ error: 'bad json' }, 400); } const url = body.url; if (!url || typeof url !== 'string') return json({ error: 'missing url' }, 400); let parsed: URL; try { parsed = new URL(url); } catch { return json({ error: 'invalid url' }, 400); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return json({ error: 'unsupported scheme' }, 400); } const client = createClient(SUPABASE_URL, SERVICE_ROLE_KEY); // Cache lookup. const { data: cached } = await client .from('link_previews') .select('*') .eq('url', url) .maybeSingle(); if ( cached && Date.now() - new Date(cached.fetched_at).getTime() < MAX_CACHE_AGE_MS ) { return json(toClientShape(cached)); } // Fetch HTML with timeout. let html = ''; let ok = true; let error: string | null = null; try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); const res = await fetch(url, { signal: ctrl.signal, headers: { 'user-agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)', accept: 'text/html,application/xhtml+xml', }, redirect: 'follow', }); clearTimeout(timer); if (!res.ok) throw new Error('status ' + res.status); const ct = res.headers.get('content-type') ?? ''; if (!ct.includes('html')) throw new Error('not html'); const reader = res.body?.getReader(); if (!reader) throw new Error('no body'); const decoder = new TextDecoder(); let total = 0; for (;;) { const { value, done } = await reader.read(); if (done) break; if (!value) continue; total += value.byteLength; html += decoder.decode(value, { stream: true }); if (total >= MAX_HTML_BYTES) { await reader.cancel(); break; } } } catch (err) { ok = false; error = err instanceof Error ? err.message : 'fetch failed'; } const meta = ok ? parseMeta(html, parsed) : null; // Upsert cache row so even failures cache briefly. const row = { url, title: meta?.title ?? null, description: meta?.description ?? null, image_url: meta?.imageUrl ?? null, site_name: meta?.siteName ?? null, ok, error, fetched_at: new Date().toISOString(), }; await client.from('link_previews').upsert(row); return json(toClientShape(row)); }); interface MetaRow { url: string; title: string | null; description: string | null; image_url: string | null; site_name: string | null; ok: boolean; } function toClientShape(row: MetaRow) { return { url: row.url, title: row.title, description: row.description, imageUrl: row.image_url, siteName: row.site_name, ok: row.ok, }; } function parseMeta(html: string, baseUrl: URL): { title: string | null; description: string | null; imageUrl: string | null; siteName: string | null; } { // Cheap regex-based parser — no DOM in Deno runtime without extra deps. // Extracts values +