Files
avbeat-com/src/app/api/ai/fetch-url/route.ts
Claude d43f78b161 av: clone broadcastbeat sources + AV Beat rebrand (Phase 1)
- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch)
- Domain: broadcastbeat.com -> avbeat.com
- Brand: Broadcast Beat -> AV Beat
- Slug: broadcastbeat -> avbeat (package, identifiers)
- Schema fallback: bb -> av (env var name unchanged)
- Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg
- All BB/RMP logo references repointed

Outstanding before public DNS swap:
  - Supabase av schema bootstrap (mirror of bb tables)
  - WordPress archive import (avbeat-com -> av.articles)
  - Coolify env vars
  - dev-avbeat.onsethost.com staging deploy + visual review
2026-06-02 15:32:56 +00:00

93 lines
3.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// ─── Server-side URL fetch proxy (bypasses CORS) ──────────────────────────────
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { data: profile } = await supabase.from('user_profiles').select('role').eq('id', user.id).single();
if (!['administrator', 'admin'].includes(profile?.role || '')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { url } = await request.json();
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
// Validate URL
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
}
// Only allow http/https
if (!['http:', 'https:'].includes(parsed.protocol)) {
return NextResponse.json({ error: 'Only http/https URLs allowed' }, { status: 400 });
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
let response: Response;
try {
response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; AV Beat/1.0; +https://avbeat.com)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (!response.ok) {
return NextResponse.json({ error: `Fetch failed: HTTP ${response.status}` }, { status: 400 });
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('text/plain') && !contentType.includes('application/xhtml')) {
return NextResponse.json({ error: 'URL does not return readable content' }, { status: 400 });
}
const html = await response.text();
// Strip HTML tags and extract readable text
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
.replace(/<header[\s\S]*?<\/header>/gi, '')
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
.replace(/<aside[\s\S]*?<\/aside>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s{2,}/g, ' ')
.trim();
// Limit to ~8000 chars to fit in context window
const truncated = text.slice(0, 8000);
const wordCount = truncated.split(/\s+/).filter(Boolean).length;
return NextResponse.json({
content: truncated,
wordCount,
domain: parsed.hostname,
url,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}