← back to Norma
app/api/nonprofit/enrich/route.ts
298 lines
import { NextRequest, NextResponse } from 'next/server';
import { assertPublicUrl } from '@/lib/ssrf-guard';
// --------------------------------------------------------------------------
// Types
// --------------------------------------------------------------------------
interface EnrichResult {
title: string | null;
description: string | null;
og_title: string | null;
og_description: string | null;
og_image: string | null;
twitter_handle: string | null;
facebook_url: string | null;
linkedin_url: string | null;
instagram_url: string | null;
youtube_url: string | null;
tiktok_url: string | null;
favicon: string | null;
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
/**
* Resolve a potentially relative URL against a base URL.
*/
function resolveUrl(href: string, baseUrl: string): string {
try {
return new URL(href, baseUrl).toString();
} catch {
return href;
}
}
/**
* Escape special regex characters.
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract the content attribute from a meta tag match.
*/
function metaContent(html: string, nameOrProperty: string): string | null {
// Match both name="..." and property="..." variants
const patterns = [
new RegExp(
`<meta[^>]*(?:name|property)=["']${escapeRegex(nameOrProperty)}["'][^>]*content=["']([^"']+)["']`,
'i',
),
new RegExp(
`<meta[^>]*content=["']([^"']+)["'][^>]*(?:name|property)=["']${escapeRegex(nameOrProperty)}["']`,
'i',
),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match?.[1]) return match[1].trim();
}
return null;
}
/**
* Extract the page <title> from HTML.
*/
function extractTitle(html: string): string | null {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.trim() || null;
}
/**
* Extract a favicon URL from <link> tags.
*/
function extractFavicon(html: string, baseUrl: string): string | null {
const patterns = [
/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']/i,
/<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']/i,
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match?.[1]) return resolveUrl(match[1].trim(), baseUrl);
}
return null;
}
/**
* Find social media profile links from anchor tags and meta tags in the HTML.
*/
function extractSocialLinks(html: string): {
twitter: string | null;
facebook: string | null;
linkedin: string | null;
instagram: string | null;
youtube: string | null;
tiktok: string | null;
} {
const result = {
twitter: null as string | null,
facebook: null as string | null,
linkedin: null as string | null,
instagram: null as string | null,
youtube: null as string | null,
tiktok: null as string | null,
};
// Grab all href values from the page
const hrefPattern = /href=["']([^"']+)["']/gi;
let match: RegExpExecArray | null;
const hrefs: string[] = [];
while ((match = hrefPattern.exec(html)) !== null) {
hrefs.push(match[1]);
}
for (const href of hrefs) {
const lower = href.toLowerCase();
if (!result.twitter && (lower.includes('twitter.com/') || lower.includes('x.com/'))) {
// Skip share/intent links
if (!lower.includes('/intent/') && !lower.includes('/share')) {
result.twitter = href;
}
}
if (!result.facebook && lower.includes('facebook.com/')) {
if (!lower.includes('/sharer') && !lower.includes('/share')) {
result.facebook = href;
}
}
if (!result.linkedin && lower.includes('linkedin.com/')) {
if (!lower.includes('/share')) {
result.linkedin = href;
}
}
if (!result.instagram && lower.includes('instagram.com/')) {
result.instagram = href;
}
if (!result.youtube && (lower.includes('youtube.com/') || lower.includes('youtu.be/'))) {
result.youtube = href;
}
if (!result.tiktok && lower.includes('tiktok.com/')) {
result.tiktok = href;
}
}
// Also check meta tags for twitter handle
if (!result.twitter) {
const twitterSite = metaContent(html, 'twitter:site');
if (twitterSite) {
const handle = twitterSite.startsWith('@') ? twitterSite : `@${twitterSite}`;
result.twitter = `https://twitter.com/${handle.replace('@', '')}`;
}
}
return result;
}
/**
* Extract a Twitter handle from a twitter URL or meta tag value.
*/
function twitterHandleFromUrl(urlOrHandle: string): string | null {
if (!urlOrHandle) return null;
// Already a handle
if (urlOrHandle.startsWith('@')) return urlOrHandle;
// Extract from URL
const match = urlOrHandle.match(/(?:twitter\.com|x\.com)\/(@?[\w]+)/i);
if (match?.[1]) {
const handle = match[1].startsWith('@') ? match[1] : `@${match[1]}`;
return handle;
}
return null;
}
// --------------------------------------------------------------------------
// POST /api/nonprofit/enrich
// Fetch a website URL and extract meta info for auto-populating signup.
// No auth required.
// --------------------------------------------------------------------------
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url } = body as { url?: string };
if (!url || typeof url !== 'string') {
return NextResponse.json(
{ error: 'A "url" field is required' },
{ status: 400 },
);
}
// Normalize URL
let normalizedUrl = url.trim();
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
normalizedUrl = `https://${normalizedUrl}`;
}
// SSRF guard: validate protocol + block private/reserved/loopback hosts
// (also resolves DNS so a hostname pointing at a private IP is blocked).
const guard = await assertPublicUrl(normalizedUrl);
if (!guard.ok) {
return NextResponse.json({ error: guard.reason }, { status: 400 });
}
normalizedUrl = guard.url!;
// Fetch the page with a timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
let html: string;
try {
const res = await fetch(normalizedUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Norma-EnrichBot/1.0)',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
redirect: 'follow',
});
clearTimeout(timeout);
if (!res.ok) {
return NextResponse.json(
{ error: `Failed to fetch URL: HTTP ${res.status}` },
{ status: 422 },
);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
return NextResponse.json(
{ error: 'URL did not return an HTML page' },
{ status: 422 },
);
}
html = await res.text();
} catch (fetchErr) {
clearTimeout(timeout);
const message = (fetchErr as Error).name === 'AbortError'
? 'Request timed out after 15 seconds'
: `Failed to fetch URL: ${(fetchErr as Error).message}`;
return NextResponse.json({ error: message }, { status: 422 });
}
// Limit HTML processing to first 200KB to avoid memory issues
const truncatedHtml = html.slice(0, 200_000);
// Extract all the metadata
const title = extractTitle(truncatedHtml);
const ogTitle = metaContent(truncatedHtml, 'og:title');
const description = metaContent(truncatedHtml, 'description');
const ogDescription = metaContent(truncatedHtml, 'og:description');
const ogImage = metaContent(truncatedHtml, 'og:image');
const favicon = extractFavicon(truncatedHtml, normalizedUrl);
const socialLinks = extractSocialLinks(truncatedHtml);
// Derive a twitter handle from the found URL
const twitterHandle = twitterHandleFromUrl(socialLinks.twitter || '') ||
(() => {
const site = metaContent(truncatedHtml, 'twitter:site');
return site ? (site.startsWith('@') ? site : `@${site}`) : null;
})();
const enrichResult: EnrichResult = {
title: ogTitle || title,
description: ogDescription || description,
og_title: ogTitle,
og_description: ogDescription,
og_image: ogImage ? resolveUrl(ogImage, normalizedUrl) : null,
twitter_handle: twitterHandle,
facebook_url: socialLinks.facebook,
linkedin_url: socialLinks.linkedin,
instagram_url: socialLinks.instagram,
youtube_url: socialLinks.youtube,
tiktok_url: socialLinks.tiktok,
favicon: favicon,
};
return NextResponse.json({
url: normalizedUrl,
data: enrichResult,
});
} catch (err) {
console.error('[api/nonprofit/enrich] POST error:', (err as Error).message);
return NextResponse.json(
{ error: 'Enrichment failed' },
{ status: 500 },
);
}
}