fixes: image-url proxy + raw-publish fallback on rewrite failure
1. Clean image URLs
Article HTML was leaking the full Supabase Storage URL:
https://supabase.onsethost.com/storage/v1/object/public/distribute-media/<uuid>/<file>
Now rewritten on render to:
/img/distribute-media/<uuid>/<file>
Served by new /img/[...path] route handler that proxies to Supabase
with 24h public cache + 7d SWR. Better SEO (first-party images) and
stops leaking the storage host into article markup.
2. Stuck PR submissions
When the rewrite pipeline fails (Ollama 503 / bad model output), the
submission used to sit forever in ai_original_submissions without
becoming a public wp_imported_posts row. PR firms got "delivered"
status with no visible article anywhere.
/api/ai/submit now calls publishRawFallback() if rewriteSubmission
returns anything other than 'succeeded'. The fallback inserts a
wp_imported_posts row containing the raw HTML body with a visible
"Distributed as a press release — staff editorial rewrite pending"
banner at the top, so the article surfaces immediately. A future
retry job can swap in the rewritten content when Ollama recovers.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
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";
|
import { findViolations } from "@/lib/disallowed-phrases-guard";
|
||||||
|
import { publishRawFallback } from "@/lib/ai/publish-raw-fallback";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
@@ -117,7 +118,24 @@ export async function POST(req: NextRequest) {
|
|||||||
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 });
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, submissionId, outcome });
|
// If the rewrite failed (Ollama 503 / model output bad), publish the raw
|
||||||
|
// press release with a "rewrite pending" notice so the submission is at
|
||||||
|
// least visible. Without this, PR-firm submissions disappear when Ollama
|
||||||
|
// is saturated.
|
||||||
|
let fallback: { wp_id: number; wp_slug: string } | null = null;
|
||||||
|
if (outcome.status !== "succeeded") {
|
||||||
|
const meta: any = payload.metadata || {};
|
||||||
|
fallback = await publishRawFallback({
|
||||||
|
submissionId,
|
||||||
|
rawTitle: payload.rawTitle,
|
||||||
|
rawHtml: payload.rawHtml || null,
|
||||||
|
rawContent: payload.rawContent,
|
||||||
|
contributorName: payload.contributorName || null,
|
||||||
|
featuredImageUrl: typeof meta.featured_image_url === "string" ? meta.featured_image_url : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, submissionId, outcome, raw_fallback: fallback });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ error: err.message || String(err) }, { status: 500 });
|
return NextResponse.json({ error: err.message || String(err) }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/app/img/[...path]/route.ts
Normal file
44
src/app/img/[...path]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-static";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image proxy: serves files from Supabase Storage under a clean broadcastbeat.com
|
||||||
|
* URL so article HTML doesn't ship the long `https://supabase.onsethost.com/
|
||||||
|
* storage/v1/object/public/...` path. Better for SEO + makes images look
|
||||||
|
* first-party.
|
||||||
|
*
|
||||||
|
* Usage: /img/distribute-media/<uuid>/<file>.jpg
|
||||||
|
* /img/campaign-creatives/<uuid>/<file>.jpg
|
||||||
|
* /img/bb-forum-avatars/<file>.png
|
||||||
|
*
|
||||||
|
* Path segments are forwarded directly to the matching public bucket.
|
||||||
|
*/
|
||||||
|
const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com")
|
||||||
|
.replace(/\/+$/, "") + "/storage/v1/object/public";
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await params;
|
||||||
|
if (!path || path.length < 2) return new Response("bad path", { status: 400 });
|
||||||
|
|
||||||
|
const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`;
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, { cache: "force-cache" });
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response("upstream error: " + (e?.message || String(e)), { status: 502 });
|
||||||
|
}
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return new Response("not found", { status: upstream.status === 404 ? 404 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": upstream.headers.get("content-type") || "image/jpeg",
|
||||||
|
"cache-control": "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800",
|
||||||
|
"x-bb-img-source": "supabase-storage",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
64
src/lib/ai/publish-raw-fallback.ts
Normal file
64
src/lib/ai/publish-raw-fallback.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Fallback: when the Ollama rewrite pipeline fails, publish the raw press
|
||||||
|
* release directly into wp_imported_posts so the article still surfaces on
|
||||||
|
* the public site. Includes a visible "rewrite pending" banner so editors
|
||||||
|
* (and savvy readers) can tell it hasn't been editorialized yet.
|
||||||
|
*
|
||||||
|
* Used by /api/ai/submit after rewriteSubmission returns a failed outcome.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createAdminClient } from "../supabase/admin";
|
||||||
|
|
||||||
|
export async function publishRawFallback(opts: {
|
||||||
|
submissionId: string;
|
||||||
|
rawTitle: string;
|
||||||
|
rawHtml?: string | null;
|
||||||
|
rawContent: string;
|
||||||
|
contributorName?: string | null;
|
||||||
|
featuredImageUrl?: string | null;
|
||||||
|
}): Promise<{ wp_id: number; wp_slug: string } | null> {
|
||||||
|
try {
|
||||||
|
const sb = createAdminClient();
|
||||||
|
|
||||||
|
// Slug from title (lower-cased, dashes only, capped) — plus the submission
|
||||||
|
// id tail so concurrent same-title submissions don't collide.
|
||||||
|
const baseSlug = (opts.rawTitle || "submission")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
const tail = opts.submissionId.replace(/-/g, "").slice(0, 6);
|
||||||
|
const wp_slug = `${baseSlug}-pr-${tail}`;
|
||||||
|
|
||||||
|
const NOTICE = `<aside style="background:#0d1726;border:1px solid #1e3552;padding:10px 14px;margin-bottom:16px;font-size:13px;color:#7aa7d4;border-radius:4px">Distributed as a press release — staff editorial rewrite pending.</aside>`;
|
||||||
|
const content = NOTICE + (opts.rawHtml || `<p>${opts.rawContent.replace(/\n+/g, "</p><p>")}</p>`);
|
||||||
|
|
||||||
|
const excerpt = opts.rawContent.replace(/\s+/g, " ").slice(0, 240).trim();
|
||||||
|
const wp_id = -Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const { error } = await sb.from("wp_imported_posts").insert({
|
||||||
|
wp_id,
|
||||||
|
wp_slug,
|
||||||
|
title: opts.rawTitle,
|
||||||
|
excerpt,
|
||||||
|
content,
|
||||||
|
author_name: opts.contributorName || "PR Submission",
|
||||||
|
category: "industry",
|
||||||
|
tags: [] as string[],
|
||||||
|
featured_image: opts.featuredImageUrl || null,
|
||||||
|
status: "published",
|
||||||
|
wp_published_at: new Date().toISOString(),
|
||||||
|
imported_at: new Date().toISOString(),
|
||||||
|
ai_rewritten_at: null,
|
||||||
|
site_id: 1,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
console.warn(`[publish-raw-fallback] insert failed: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { wp_id, wp_slug };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`[publish-raw-fallback] unhandled: ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,18 @@ export function rewriteLegacyImageUrl(url: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rewriteLegacyImageUrlsInHtml(html: string): string {
|
export function rewriteLegacyImageUrlsInHtml(html: string): string {
|
||||||
return html.replace(
|
let out = html.replace(
|
||||||
/(https?:\/\/(?:www\.)?broadcastbeat\.com\/wp-content\/uploads\/[^"'\s)]+)/g,
|
/(https?:\/\/(?:www\.)?broadcastbeat\.com\/wp-content\/uploads\/[^"'\s)]+)/g,
|
||||||
(m) => rewriteLegacyImageUrl(m),
|
(m) => rewriteLegacyImageUrl(m),
|
||||||
);
|
);
|
||||||
|
// Rewrite raw Supabase Storage URLs to clean first-party /img/<bucket>/<path>
|
||||||
|
// so article HTML never leaks the long supabase.onsethost.com URL. Better for
|
||||||
|
// SEO and keeps images visually first-party. Served by /img/[...path]/route.ts.
|
||||||
|
out = out.replace(
|
||||||
|
/https?:\/\/(?:[\w-]+\.)*supabase\.onsethost\.com\/storage\/v1\/object\/public\/([^\s"')]+)/g,
|
||||||
|
(_m, p) => `/img/${p}`,
|
||||||
|
);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user