fix: auto-publish approved blog submissions on approval

Approved user submissions now import into the blog automatically, with a
backfill script for existing approvals and a WebSocket polyfill so backend
Nostr relay queries work on Node 20.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-28 23:38:48 +02:00
co-authored by Cursor
parent a6a2b113ee
commit 99380ef6aa
8 changed files with 306 additions and 62 deletions
+34 -1
View File
@@ -20,7 +20,8 @@
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -30,6 +31,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
@@ -807,6 +809,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -2553,6 +2565,27 @@
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+5 -2
View File
@@ -10,7 +10,8 @@
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy",
"backfill-submissions": "dotenv -e ../.env -e .env -- tsx scripts/backfill-approved-submissions.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -28,7 +29,8 @@
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -38,6 +40,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
@@ -0,0 +1,64 @@
// One-time backfill: publish any APPROVED submissions that never got a blog
// Post (they predate auto-publish-on-approval). Idempotent — already-imported
// submissions are skipped, so it is safe to re-run.
//
// Usage (from backend/):
// npm run backfill-submissions
import { prisma } from '../src/db/prisma';
import { importPostFromNostr, resolveSubmissionImport } from '../src/services/postImport';
async function main() {
const approved = await prisma.submission.findMany({
where: { status: 'APPROVED' },
orderBy: { createdAt: 'asc' },
});
console.log(`Found ${approved.length} APPROVED submission(s).`);
let imported = 0;
let skipped = 0;
let failed = 0;
for (const submission of approved) {
const label = `"${submission.title}" (${submission.id})`;
try {
const importInput = await resolveSubmissionImport(submission);
if (!importInput) {
console.warn(` FAILED ${label}: could not resolve Nostr event from relays.`);
failed++;
continue;
}
const existing = await prisma.post.findUnique({
where: { nostrEventId: importInput.nostrEventId },
});
if (existing) {
console.log(` SKIP ${label}: already published (slug: ${existing.slug}).`);
skipped++;
continue;
}
const post = await importPostFromNostr(importInput);
console.log(` IMPORT ${label}: published (slug: ${post?.slug}).`);
imported++;
} catch (err) {
console.error(` FAILED ${label}:`, err);
failed++;
}
}
console.log(`\nDone. Imported: ${imported}, Skipped: ${skipped}, Failed: ${failed}.`);
}
main()
.then(async () => {
await prisma.$disconnect();
// The Nostr relay pool keeps websockets open, which would otherwise keep the
// process alive after the work is done.
process.exit(0);
})
.catch(async (err) => {
console.error('Backfill error:', err);
await prisma.$disconnect();
process.exit(1);
});
+43 -57
View File
@@ -1,10 +1,40 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
import { nostrService } from '../services/nostr';
import { importPostFromNostr } from '../services/postImport';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router();
// Resolves access for an optional Bearer token (JWT or API key) without
// rejecting unauthenticated callers, so listing endpoints can stay public while
// still recognizing staff who pass `?all=true`.
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return null;
const token = header.slice(7);
try {
if (looksLikeApiKey(token)) {
return await resolveApiKey(token);
}
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
return await authService.resolveAccess(payload.pubkey);
} catch {
return null;
}
}
// Returns true when the requester is authenticated and may see hidden posts.
async function canViewHiddenPosts(req: Request): Promise<boolean> {
const access = await resolveOptionalAccess(req);
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
}
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
@@ -12,7 +42,10 @@ router.get('/', async (req: Request, res: Response) => {
const category = req.query.category as string | undefined;
const skip = (page - 1) * limit;
const where: any = { visible: true };
const includeHidden = req.query.all === 'true' && (await canViewHiddenPosts(req));
const where: any = {};
if (!includeHidden) where.visible = true;
if (category) {
where.categories = {
some: { category: { slug: category } },
@@ -34,6 +67,7 @@ router.get('/', async (req: Request, res: Response) => {
res.json({
posts,
total,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (err) {
@@ -76,62 +110,14 @@ router.post(
return;
}
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
const post = await prisma.post.upsert({
where: { nostrEventId },
update: {
title,
excerpt: excerpt || undefined,
naddr: naddr || undefined,
},
create: {
nostrEventId,
naddr: naddr || null,
title,
slug,
excerpt: excerpt || null,
authorPubkey,
publishedAt: publishedAt
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
: new Date(),
},
});
if (Array.isArray(tags) && tags.length > 0) {
const categoryIds: string[] = [];
for (const tag of tags as string[]) {
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (!catSlug) continue;
const category = await prisma.category.upsert({
where: { slug: catSlug },
update: {},
create: {
name: tag.charAt(0).toUpperCase() + tag.slice(1),
slug: catSlug,
},
});
categoryIds.push(category.id);
}
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
if (categoryIds.length > 0) {
await prisma.postCategory.createMany({
data: categoryIds.map((categoryId) => ({
postId: post.id,
categoryId,
})),
});
}
}
const result = await prisma.post.findUnique({
where: { id: post.id },
include: { categories: { include: { category: true } } },
const result = await importPostFromNostr({
nostrEventId,
naddr,
title,
excerpt,
authorPubkey,
publishedAt,
tags,
});
res.json(result);
+18 -1
View File
@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { importPostFromNostr, resolveSubmissionImport } from '../services/postImport';
const router = Router();
@@ -94,6 +95,22 @@ router.patch(
return;
}
// Approval publishes the referenced Nostr article to the blog. Do this
// before flipping the status so a failed import doesn't leave a submission
// marked APPROVED without a corresponding live post.
let post = null;
if (status === 'APPROVED') {
const importInput = await resolveSubmissionImport(submission);
if (!importInput) {
res.status(422).json({
error:
'Could not resolve the submitted Nostr event from relays. The post was not published, so the submission was left unchanged.',
});
return;
}
post = await importPostFromNostr(importInput);
}
const updated = await prisma.submission.update({
where: { id: req.params.id as string },
data: {
@@ -103,7 +120,7 @@ router.patch(
},
});
res.json(updated);
res.json({ submission: updated, post });
} catch (err) {
console.error('Review submission error:', err);
res.status(500).json({ error: 'Internal server error' });
+8
View File
@@ -1,6 +1,14 @@
import WebSocket from 'ws';
import { SimplePool, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
// nostr-tools' SimplePool relies on a global WebSocket. Node only exposes one
// from v22 onward, so on older runtimes (this backend runs Node 20) every relay
// query silently fails without this polyfill. Set before any pool connects.
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') {
(globalThis as { WebSocket?: unknown }).WebSocket = WebSocket;
}
const pool = new SimplePool();
async function getRelayUrls(): Promise<string[]> {
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from '../db/prisma';
import { nostrService } from './nostr';
export interface ImportPostInput {
nostrEventId: string;
naddr?: string | null;
title: string;
excerpt?: string | null;
authorPubkey: string;
publishedAt?: number | string | null;
tags?: string[];
visible?: boolean;
}
// Upserts a blog Post from a Nostr longform event. Shared by the manual import
// endpoint and the submission-approval flow so both produce identical results
// (auto slug, category links from `t` tags, visible by default).
export async function importPostFromNostr(input: ImportPostInput) {
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags, visible } = input;
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
const post = await prisma.post.upsert({
where: { nostrEventId },
update: {
title,
excerpt: excerpt || undefined,
naddr: naddr || undefined,
},
create: {
nostrEventId,
naddr: naddr || null,
title,
slug,
excerpt: excerpt || null,
authorPubkey,
visible: visible ?? true,
publishedAt: publishedAt
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
: new Date(),
},
});
if (Array.isArray(tags) && tags.length > 0) {
const categoryIds: string[] = [];
for (const tag of tags) {
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (!catSlug) continue;
const category = await prisma.category.upsert({
where: { slug: catSlug },
update: {},
create: {
name: tag.charAt(0).toUpperCase() + tag.slice(1),
slug: catSlug,
},
});
categoryIds.push(category.id);
}
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
if (categoryIds.length > 0) {
await prisma.postCategory.createMany({
data: categoryIds.map((categoryId) => ({
postId: post.id,
categoryId,
})),
});
}
}
return prisma.post.findUnique({
where: { id: post.id },
include: { categories: { include: { category: true } } },
});
}
export interface SubmissionRef {
eventId: string | null;
naddr: string | null;
title: string;
authorPubkey: string;
}
// Resolves the Nostr longform event referenced by a submission into a blog
// import payload. Returns null when the event cannot be resolved into something
// publishable (e.g. naddr that no relay can serve and no fallback event id).
export async function resolveSubmissionImport(
submission: SubmissionRef
): Promise<ImportPostInput | null> {
const event = submission.naddr
? await nostrService.fetchLongformEvent(submission.naddr)
: submission.eventId
? await nostrService.fetchEvent(submission.eventId)
: null;
const nostrEventId: string | undefined = event?.id || submission.eventId || undefined;
if (!nostrEventId) return null;
const eventTags: string[][] = Array.isArray(event?.tags) ? (event!.tags as string[][]) : [];
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1];
const topicTags = eventTags
.filter((t) => t[0] === 't' && t[1])
.map((t) => (t[1] as string).toLowerCase());
const excerpt = ((event?.content as string) || '')
.slice(0, 200)
.replace(/[#*_\n]/g, '')
.trim();
return {
nostrEventId,
naddr: submission.naddr || undefined,
title: submission.title || titleTag || 'Untitled',
excerpt: excerpt || undefined,
authorPubkey: event?.pubkey || submission.authorPubkey,
publishedAt: event?.created_at ?? null,
tags: topicTags.length > 0 ? topicTags : undefined,
visible: true,
};
}
+10 -1
View File
@@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() {
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState("");
const loadSubmissions = async () => {
try {
@@ -61,10 +62,17 @@ export default function AdminSubmissionsPage() {
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
setProcessing(true);
setError("");
setSuccess("");
try {
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
const res = await api.reviewSubmission(id, {
status,
reviewNote: reviewNote.trim() || undefined,
});
setReviewingId(null);
setReviewNote("");
if (status === "APPROVED" && res?.post) {
setSuccess(`Approved and published "${res.post.title}" to the blog.`);
}
await loadSubmissions();
} catch (err: any) {
setError(err.message);
@@ -87,6 +95,7 @@ export default function AdminSubmissionsPage() {
</div>
{error && <p className="text-error text-sm">{error}</p>}
{success && <p className="text-primary text-sm">{success}</p>}
<div className="flex gap-2">
{TABS.map((tab) => (