← 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 },
    );
  }
}