diff --git a/backend/package-lock.json b/backend/package-lock.json index a9b1ee1..4d80fd4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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 + } + } } } } diff --git a/backend/package.json b/backend/package.json index ad06e70..0b9787e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/scripts/backfill-approved-submissions.ts b/backend/scripts/backfill-approved-submissions.ts new file mode 100644 index 0000000..3380cb7 --- /dev/null +++ b/backend/scripts/backfill-approved-submissions.ts @@ -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); + }); diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts index 7d5250b..6bc6391 100644 --- a/backend/src/api/posts.ts +++ b/backend/src/api/posts.ts @@ -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 { + 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 { + 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); diff --git a/backend/src/api/submissions.ts b/backend/src/api/submissions.ts index 07f6a6d..26d001d 100644 --- a/backend/src/api/submissions.ts +++ b/backend/src/api/submissions.ts @@ -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' }); diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 7aede6e..17c627d 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -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 { diff --git a/backend/src/services/postImport.ts b/backend/src/services/postImport.ts new file mode 100644 index 0000000..9894b1f --- /dev/null +++ b/backend/src/services/postImport.ts @@ -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 { + 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, + }; +} diff --git a/frontend/app/admin/submissions/page.tsx b/frontend/app/admin/submissions/page.tsx index 46b3a24..0d6dbc3 100644 --- a/frontend/app/admin/submissions/page.tsx +++ b/frontend/app/admin/submissions/page.tsx @@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() { const [reviewingId, setReviewingId] = useState(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() { {error &&

{error}

} + {success &&

{success}

}
{TABS.map((tab) => (