Even though distribute-rmp gates the submitter, re-validate the contributor's distribute.users.status here before inserting into bb.native_articles. A leaked DISTRIBUTE_SHARED_SECRET shouldn't be enough to bypass the approval system. Falls back to letting the request through on transient lookup failure (distribute side already checked). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
import { NextResponse, type NextRequest } from 'next/server';
|
|
import { createClient } from '@supabase/supabase-js';
|
|
import { findViolations } from '@/lib/disallowed-phrases-guard';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
function slugify(input: string): string {
|
|
return (input || 'submission')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.trim()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.slice(0, 80) || 'submission';
|
|
}
|
|
|
|
function randSuffix(): string {
|
|
return Math.random().toString(36).slice(2, 8);
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const auth = request.headers.get('authorization') || '';
|
|
const expected = process.env.DISTRIBUTE_SHARED_SECRET;
|
|
if (!expected) return NextResponse.json({ error: 'receiver not configured' }, { status: 500 });
|
|
if (!auth.startsWith('Bearer ') || auth.slice(7) !== expected) {
|
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
let body: any;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
|
|
}
|
|
|
|
const title = (body?.title || '').toString().trim();
|
|
const content = (body?.body || '').toString();
|
|
if (!title || !content) return NextResponse.json({ error: 'title and body are required' }, { status: 400 });
|
|
|
|
// Editorial guardrail: reject anything mentioning a disallowed phrase
|
|
// (competitor publications, banned exhibitors, etc.) before it ever lands
|
|
// in bb.native_articles.
|
|
const violations = await findViolations(`${title}\n${content}`);
|
|
if (violations.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Submission rejected: contains disallowed phrases', violations },
|
|
{ status: 422 },
|
|
);
|
|
}
|
|
|
|
const contributor = body?.contributor || {};
|
|
const featuredImage = body?.featured_image_url || null;
|
|
const attachments = Array.isArray(body?.attachments) ? body.attachments : [];
|
|
const submissionId = body?.submission_id || null;
|
|
const property = body?.property || 'broadcastbeat';
|
|
|
|
// Defense in depth: re-check the contributor's approval status against
|
|
// distribute.users. distribute-rmp's submit route already gates, but a
|
|
// belt-and-suspenders check here means even a leaked DISTRIBUTE_SHARED_SECRET
|
|
// can't shove articles in from non-approved users.
|
|
if (contributor?.id) {
|
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
const srKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
if (url && srKey) {
|
|
try {
|
|
const r = await fetch(
|
|
`${url}/rest/v1/users?id=eq.${encodeURIComponent(contributor.id)}&select=status&limit=1`,
|
|
{
|
|
headers: {
|
|
apikey: srKey,
|
|
Authorization: `Bearer ${srKey}`,
|
|
'Accept-Profile': 'distribute',
|
|
},
|
|
cache: 'no-store',
|
|
},
|
|
);
|
|
const rows = (await r.json()) as Array<{ status: string }>;
|
|
const status = Array.isArray(rows) && rows.length ? rows[0].status : null;
|
|
if (status !== 'approved') {
|
|
return NextResponse.json(
|
|
{ error: 'Contributor not approved', status },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
} catch {
|
|
// If the lookup fails, fall back to letting the request through
|
|
// (distribute-rmp side already gated). Don't break the pipeline on
|
|
// a transient supabase glitch.
|
|
}
|
|
}
|
|
}
|
|
|
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
if (!url || !key) return NextResponse.json({ error: 'supabase not configured' }, { status: 500 });
|
|
const supabase = createClient(url, key, { auth: { persistSession: false } });
|
|
|
|
const baseSlug = slugify(title);
|
|
const slug = `${baseSlug}-${randSuffix()}`;
|
|
|
|
const excerpt = content
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 240);
|
|
|
|
const { data: article, error: artErr } = await supabase
|
|
.schema('bb')
|
|
.from('native_articles')
|
|
.insert({
|
|
slug,
|
|
title,
|
|
content,
|
|
excerpt,
|
|
author_name: contributor?.contact_name || contributor?.firm_name || 'PR Submission',
|
|
category: 'latest',
|
|
featured_image: featuredImage,
|
|
status: 'pending_pr_rewrite',
|
|
})
|
|
.select('id, slug')
|
|
.single();
|
|
|
|
if (artErr) return NextResponse.json({ error: artErr.message, where: 'native_articles insert' }, { status: 500 });
|
|
|
|
const { error: subErr } = await supabase
|
|
.schema('bb')
|
|
.from('original_submissions')
|
|
.insert({
|
|
submission_id: submissionId,
|
|
property,
|
|
title,
|
|
body: content,
|
|
featured_image_url: featuredImage,
|
|
attachments,
|
|
contributor,
|
|
contributor_email: contributor?.email || null,
|
|
raw_payload: body,
|
|
native_article_id: article.id,
|
|
});
|
|
|
|
if (subErr) return NextResponse.json({ error: subErr.message, where: 'original_submissions insert', article_id: article.id }, { status: 500 });
|
|
|
|
return NextResponse.json({ ok: true, article_id: article.id, slug: article.slug, status: 'pending_pr_rewrite' }, { status: 201 });
|
|
}
|