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:
Generated
+34
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user