- 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
93 lines
3.2 KiB
TypeScript
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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 });
|
|
}
|
|
}
|