ingestion: reject submissions containing disallowed phrases
/api/ai/submit and /api/submission-receive now scan incoming payload title + body against the bb.disallowed_phrases allowlist (cached 3 min) and return 422 with the matching phrases if any are found. Stops competitor mentions from re-entering the pipeline after the bulk purge.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { ingestRawSubmission, rewriteSubmission } from "@/lib/ai/rewrite-pipeline";
|
import { ingestRawSubmission, rewriteSubmission } from "@/lib/ai/rewrite-pipeline";
|
||||||
|
import { findViolations } from "@/lib/disallowed-phrases-guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
@@ -98,6 +99,21 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject submissions whose title or body contains a disallowed phrase
|
||||||
|
// (competitor publications, banned exhibitors, etc.). Logged with the
|
||||||
|
// violation list so the sender knows what to remove.
|
||||||
|
const scanText = `${payload.rawTitle}\n${payload.rawContent}\n${payload.rawHtml || ""}`;
|
||||||
|
const violations = await findViolations(scanText);
|
||||||
|
if (violations.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Submission rejected: contains disallowed phrases",
|
||||||
|
violations,
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submissionId = await ingestRawSubmission(payload);
|
const submissionId = await ingestRawSubmission(payload);
|
||||||
const outcome = await rewriteSubmission({ submissionId, personaSlug: payload.personaSlug });
|
const outcome = await rewriteSubmission({ submissionId, personaSlug: payload.personaSlug });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { findViolations } from '@/lib/disallowed-phrases-guard';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -36,6 +37,17 @@ export async function POST(request: NextRequest) {
|
|||||||
const content = (body?.body || '').toString();
|
const content = (body?.body || '').toString();
|
||||||
if (!title || !content) return NextResponse.json({ error: 'title and body are required' }, { status: 400 });
|
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 contributor = body?.contributor || {};
|
||||||
const featuredImage = body?.featured_image_url || null;
|
const featuredImage = body?.featured_image_url || null;
|
||||||
const attachments = Array.isArray(body?.attachments) ? body.attachments : [];
|
const attachments = Array.isArray(body?.attachments) ? body.attachments : [];
|
||||||
|
|||||||
65
src/lib/disallowed-phrases-guard.ts
Normal file
65
src/lib/disallowed-phrases-guard.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Server-side ingestion guard. Used by /api/ai/submit and /api/submission-receive
|
||||||
|
* to reject incoming submissions that contain a disallowed phrase from the
|
||||||
|
* bb.disallowed_phrases table (competitor publications, banned exhibitors,
|
||||||
|
* etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createAdminClient } from "./supabase/admin";
|
||||||
|
|
||||||
|
type Phrase = {
|
||||||
|
phrase: string;
|
||||||
|
category: string;
|
||||||
|
match_mode: "word_boundary" | "substring" | "domain";
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: { rows: Phrase[]; expires: number } | null = null;
|
||||||
|
const CACHE_TTL_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
async function loadActivePhrases(): Promise<Phrase[]> {
|
||||||
|
if (cached && cached.expires > Date.now()) return cached.rows;
|
||||||
|
try {
|
||||||
|
const svc = createAdminClient();
|
||||||
|
const { data } = await svc
|
||||||
|
.from("disallowed_phrases")
|
||||||
|
.select("phrase, category, match_mode")
|
||||||
|
.eq("active", true);
|
||||||
|
const rows = (data || []) as Phrase[];
|
||||||
|
cached = { rows, expires: Date.now() + CACHE_TTL_MS };
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return cached?.rows || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Violation = { phrase: string; category: string };
|
||||||
|
|
||||||
|
/** Returns the list of phrases the text violates (empty if clean). */
|
||||||
|
export async function findViolations(text: string): Promise<Violation[]> {
|
||||||
|
if (!text) return [];
|
||||||
|
const phrases = await loadActivePhrases();
|
||||||
|
const out: Violation[] = [];
|
||||||
|
for (const p of phrases) {
|
||||||
|
const esc = escapeRegex(p.phrase);
|
||||||
|
let re: RegExp;
|
||||||
|
if (p.match_mode === "word_boundary") {
|
||||||
|
re = new RegExp(`(^|[^A-Za-z0-9])(${esc})(?![A-Za-z0-9])`, "i");
|
||||||
|
} else if (p.match_mode === "domain") {
|
||||||
|
re = new RegExp(`(?:https?://)?(?:[\\w-]+\\.)?${esc}\\b`, "i");
|
||||||
|
} else {
|
||||||
|
re = new RegExp(esc, "i");
|
||||||
|
}
|
||||||
|
if (re.test(text)) out.push({ phrase: p.phrase, category: p.category });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throw-style helper: short-circuits an API route on first violation. */
|
||||||
|
export async function assertNoViolations(text: string): Promise<{ ok: true } | { ok: false; violations: Violation[] }> {
|
||||||
|
const v = await findViolations(text);
|
||||||
|
return v.length === 0 ? { ok: true } : { ok: false, violations: v };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user