From 586b572f738b377913c1ab5fd71a58390e84a2bf Mon Sep 17 00:00:00 2001 From: bbe Date: Fri, 3 Apr 2026 18:37:52 +0200 Subject: [PATCH] feat(board): Lightning-paid message board with LNbits and admin moderation Add public /board flow: create invoice, webhook + confirm reconciliation, list active messages, likes (Nostr), zap fallbacks. Admin table for hide/delete. Include LNbits webhook body normalization (double-encoded JSON), POST /api/messages/confirm/:hash, and root npm db:push script. Prisma models for pending invoices and board messages. Made-with: Cursor --- .env.example | 16 +- backend/package.json | 8 +- backend/prisma/schema.prisma | 24 ++ backend/src/api/adminMessages.ts | 68 ++++ backend/src/api/messages.ts | 359 ++++++++++++++++ backend/src/index.ts | 4 + backend/src/services/lnbits.ts | 113 ++++++ frontend/app/admin/messages/page.tsx | 146 +++++++ frontend/app/board/layout.tsx | 16 + frontend/app/board/page.tsx | 449 +++++++++++++++++++++ frontend/components/admin/AdminSidebar.tsx | 2 + frontend/components/public/Navbar.tsx | 1 + frontend/lib/api.ts | 47 +++ package.json | 9 + 14 files changed, 1257 insertions(+), 5 deletions(-) create mode 100644 backend/src/api/adminMessages.ts create mode 100644 backend/src/api/messages.ts create mode 100644 backend/src/services/lnbits.ts create mode 100644 frontend/app/admin/messages/page.tsx create mode 100644 frontend/app/board/layout.tsx create mode 100644 frontend/app/board/page.tsx create mode 100644 package.json diff --git a/.env.example b/.env.example index d9225f0..da905fc 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,9 @@ ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2 # Nostr relays (comma-separated) RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band -# Database (use an absolute `file:` URL in production, e.g. file:/home/bbe/BelgianBitcoinEmbassy/backend/prisma/prod.db) +# Database (path is relative to backend/prisma/ when using file: URLs — see Prisma docs) +# Apply schema: from repo root run `npm run db:push`, or from backend run `npm run db:push`. +# Do not run bare `npx prisma db push` from the repo root (no schema there; wrong Prisma version). DATABASE_URL="file:./dev.db" # JWT @@ -22,3 +24,15 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy NEXT_PUBLIC_SITE_TAGLINE=Belgium's Monthly Bitcoin Meetup + +# Message board (Lightning / LNbits) — backend +MESSAGE_PRICE_SATS=1000 +LNBITS_API_KEY= +LNBITS_WEBHOOK_SECRET= +LNBITS_URL=https://legend.lnbits.com +# Public URL that LNbits can POST webhooks to (usually your site origin so /api/messages/webhook hits the API) +WEBHOOK_BASE_URL=http://localhost:3000 +# Optional: lnaddress or LNURL-pay string for “Zap BBE” when the message has no pubkey +BOARD_ZAP_LN_ADDRESS= +# Optional: hex pubkey for njump fallback when BOARD_ZAP_LN_ADDRESS is unset +BOARD_ZAP_PUBKEY= diff --git a/backend/package.json b/backend/package.json index cd041a2..95b9d90 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,10 +5,10 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "db:push": "dotenv -e ../.env -- prisma db push", - "db:seed": "dotenv -e ../.env -- prisma db seed", - "db:studio": "dotenv -e ../.env -- prisma studio", - "migrate:deploy": "dotenv -e ../.env -- prisma migrate deploy" + "db:push": "dotenv -e ../.env -e .env -- prisma db push", + "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" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ac84ef2..613ad56 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -147,3 +147,27 @@ model Faq { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +/// Pending Lightning invoice metadata (message is created only after payment via webhook). +model BoardInvoicePending { + paymentHash String @id + content String + guestName String? + pubkey String? + profilePic String? + createdAt DateTime @default(now()) +} + +/// Paid board message (LNbits payment confirmed). +model BoardMessage { + id String @id @default(uuid()) + paymentHash String @unique + content String + authorName String + pubkey String? + profilePic String? + satsPaid Int + status String @default("active") // active, hidden, deleted + likeCount Int @default(0) + createdAt DateTime @default(now()) +} diff --git a/backend/src/api/adminMessages.ts b/backend/src/api/adminMessages.ts new file mode 100644 index 0000000..e92c5cf --- /dev/null +++ b/backend/src/api/adminMessages.ts @@ -0,0 +1,68 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +router.use(requireAuth, requireRole(['ADMIN', 'MODERATOR'])); + +router.get('/', async (_req: Request, res: Response) => { + try { + const messages = await prisma.boardMessage.findMany({ + orderBy: { createdAt: 'desc' }, + }); + res.json(messages); + } catch (err) { + console.error('Admin list board messages error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** Toggle active <-> hidden */ +router.post('/:id/hide', async (req: Request, res: Response) => { + try { + const rawId = req.params.id; + const id = Array.isArray(rawId) ? rawId[0] : rawId; + const msg = await prisma.boardMessage.findUnique({ where: { id } }); + if (!msg) { + res.status(404).json({ error: 'Not found' }); + return; + } + if (msg.status === 'deleted') { + res.status(400).json({ error: 'Message is deleted' }); + return; + } + const next = msg.status === 'hidden' ? 'active' : 'hidden'; + const updated = await prisma.boardMessage.update({ + where: { id: msg.id }, + data: { status: next }, + }); + res.json(updated); + } catch (err) { + console.error('Admin hide board message error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** Soft-delete */ +router.delete('/:id', async (req: Request, res: Response) => { + try { + const rawId = req.params.id; + const id = Array.isArray(rawId) ? rawId[0] : rawId; + const msg = await prisma.boardMessage.findUnique({ where: { id } }); + if (!msg) { + res.status(404).json({ error: 'Not found' }); + return; + } + const updated = await prisma.boardMessage.update({ + where: { id: msg.id }, + data: { status: 'deleted' }, + }); + res.json(updated); + } catch (err) { + console.error('Admin delete board message error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/api/messages.ts b/backend/src/api/messages.ts new file mode 100644 index 0000000..16eef85 --- /dev/null +++ b/backend/src/api/messages.ts @@ -0,0 +1,359 @@ +import { Router, Request, Response } from 'express'; +import { timingSafeEqual } from 'crypto'; +import { prisma } from '../db/prisma'; +import { requireAuth } from '../middleware/auth'; +import { + createIncomingInvoice, + verifyIncomingPaymentPaid, + getPublicPaymentStatus, +} from '../services/lnbits'; + +const router = Router(); + +const MAX_CONTENT = 300; +const MAX_NAME = 80; +const MESSAGE_PRICE_SATS = Math.max(1, parseInt(process.env.MESSAGE_PRICE_SATS || '1000', 10) || 1000); +const WEBHOOK_BASE = (process.env.WEBHOOK_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3000').replace( + /\/$/, + '' +); +const WEBHOOK_SECRET = process.env.LNBITS_WEBHOOK_SECRET || ''; + +function sanitizeText(s: string, max: number): string { + const t = s + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '') + .replace(/<[^>]*>/g, '') + .trim(); + return t.length > max ? t.slice(0, max) : t; +} + +/** LNbits sends `json=payment.json()` where `.json()` returns a Pydantic JSON + * string — httpx double-encodes it, so Express may parse the body as a plain + * string instead of an object. Unwrap up to two layers of JSON encoding. */ +function normalizeWebhookBody(raw: unknown): unknown { + let v = raw; + for (let i = 0; i < 2 && typeof v === 'string'; i++) { + try { v = JSON.parse(v); } catch { break; } + } + return v; +} + +const HEX64 = /^[a-f0-9]{64}$/i; + +function extractPaymentHash(body: unknown): string | null { + const o = normalizeWebhookBody(body); + if (!o || typeof o !== 'object') return null; + const r = o as Record; + for (const key of ['payment_hash', 'paymentHash', 'checking_id']) { + const v = r[key]; + if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase(); + } + const p = r.payment; + if (p && typeof p === 'object') { + const nested = p as Record; + for (const key of ['payment_hash', 'paymentHash', 'checking_id']) { + const v = nested[key]; + if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase(); + } + } + return null; +} + +function verifyWebhookSecret(req: Request): boolean { + if (!WEBHOOK_SECRET) return true; + const h = req.headers['x-bbe-webhook-secret']; + if (typeof h === 'string' && safeEqualStr(h, WEBHOOK_SECRET)) return true; + const auth = req.headers.authorization; + if (auth?.startsWith('Bearer ')) { + const t = auth.slice(7); + if (safeEqualStr(t, WEBHOOK_SECRET)) return true; + } + const q = req.query.token; + const token = typeof q === 'string' ? q : Array.isArray(q) ? q[0] : ''; + if (typeof token === 'string' && token.length > 0 && safeEqualStr(token, WEBHOOK_SECRET)) { + return true; + } + return false; +} + +function safeEqualStr(a: string, b: string): boolean { + try { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); + } catch { + return false; + } +} + +/** Public: board config for the frontend */ +router.get('/config', (_req: Request, res: Response) => { + res.json({ + priceSats: MESSAGE_PRICE_SATS, + bbeZapPubkey: process.env.BOARD_ZAP_PUBKEY || null, + bbeZapAddress: process.env.BOARD_ZAP_LN_ADDRESS || null, + }); +}); + +/** Create Lightning invoice for a new message (no BoardMessage row yet). */ +router.post('/invoice', async (req: Request, res: Response) => { + try { + const { content, name, pubkey, profilePic, postAsAnon } = req.body as Record; + + if (typeof content !== 'string') { + res.status(400).json({ error: 'content is required' }); + return; + } + + const cleanContent = sanitizeText(content, MAX_CONTENT); + if (!cleanContent) { + res.status(400).json({ error: 'content is empty' }); + return; + } + + let guestName: string | null = null; + let authorPubkey: string | null = null; + let pic: string | null = null; + + const pk = typeof pubkey === 'string' && /^[a-f0-9]{64}$/i.test(pubkey) ? pubkey.toLowerCase() : null; + const asAnon = postAsAnon === true; + + if (pk && !asAnon) { + authorPubkey = pk; + if (typeof profilePic === 'string' && profilePic.length > 0 && profilePic.length < 2048) { + pic = profilePic.slice(0, 2048); + } + const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : ''; + guestName = n || null; + } else { + const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : ''; + guestName = n || null; + } + + const webhookPath = WEBHOOK_SECRET + ? `/api/messages/webhook?token=${encodeURIComponent(WEBHOOK_SECRET)}` + : '/api/messages/webhook'; + const webhookUrl = `${WEBHOOK_BASE}${webhookPath}`; + + const { payment_hash, payment_request, checking_id } = await createIncomingInvoice({ + amountSats: MESSAGE_PRICE_SATS, + memo: 'BBE message board', + webhookUrl, + }); + + await prisma.boardInvoicePending.upsert({ + where: { paymentHash: payment_hash }, + create: { + paymentHash: payment_hash, + content: cleanContent, + guestName, + pubkey: authorPubkey, + profilePic: pic, + }, + update: { + content: cleanContent, + guestName, + pubkey: authorPubkey, + profilePic: pic, + }, + }); + + res.json({ + payment_request, + checking_id, + payment_hash, + }); + } catch (err) { + console.error('Board invoice error:', err); + const msg = err instanceof Error ? err.message : 'Internal server error'; + if (msg.includes('LNBITS_API_KEY')) { + res.status(503).json({ error: 'Lightning payments are not configured' }); + return; + } + res.status(500).json({ error: msg }); + } +}); + +/** + * Shared logic: verify payment on LNbits, look up pending row, create BoardMessage. + * Returns { ok, duplicate?, ignored?, error? }. + */ +async function promotePendingToMessage(paymentHash: string): Promise<{ + ok: boolean; + duplicate?: boolean; + ignored?: string; + error?: string; +}> { + const existing = await prisma.boardMessage.findUnique({ where: { paymentHash } }); + if (existing) { + await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } }); + return { ok: true, duplicate: true }; + } + + const verified = await verifyIncomingPaymentPaid(paymentHash); + if (!verified.paid) { + return { ok: true, ignored: 'not_paid' }; + } + + const expectedMsat = MESSAGE_PRICE_SATS * 1000; + if (verified.amountMsat != null && verified.amountMsat < expectedMsat) { + console.warn('Board: amount mismatch', paymentHash, verified.amountMsat); + return { ok: true, ignored: 'amount' }; + } + + const pending = await prisma.boardInvoicePending.findUnique({ where: { paymentHash } }); + if (!pending) { + return { ok: true, ignored: 'no_pending' }; + } + + const authorName = pending.pubkey + ? pending.guestName && pending.guestName.length > 0 + ? pending.guestName + : 'Nostr user' + : pending.guestName && pending.guestName.length > 0 + ? pending.guestName + : 'anon'; + + try { + await prisma.$transaction([ + prisma.boardMessage.create({ + data: { + paymentHash, + content: pending.content, + authorName, + pubkey: pending.pubkey, + profilePic: pending.profilePic, + satsPaid: MESSAGE_PRICE_SATS, + status: 'active', + }, + }), + prisma.boardInvoicePending.delete({ where: { paymentHash } }), + ]); + } catch (e: unknown) { + const code = e && typeof e === 'object' && 'code' in e ? (e as { code: string }).code : ''; + if (code === 'P2002') { + await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } }); + return { ok: true, duplicate: true }; + } + throw e; + } + + return { ok: true }; +} + +/** LNbits calls this when an invoice is paid */ +router.post('/webhook', async (req: Request, res: Response) => { + try { + if (!verifyWebhookSecret(req)) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const paymentHash = extractPaymentHash(req.body); + if (!paymentHash) { + const preview = typeof req.body === 'string' ? req.body.slice(0, 200) : JSON.stringify(req.body).slice(0, 200); + console.warn('Board webhook: could not extract payment_hash', typeof req.body, preview); + res.status(200).json({ ok: false, error: 'missing payment_hash' }); + return; + } + + const result = await promotePendingToMessage(paymentHash); + res.json(result); + } catch (err) { + console.error('Board webhook error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** Client-side reconciliation: verify payment and create message if webhook was missed. */ +router.post('/confirm/:paymentHash', async (req: Request, res: Response) => { + try { + const raw = req.params.paymentHash; + const paymentHash = Array.isArray(raw) ? raw[0] : raw; + if (!paymentHash || !HEX64.test(paymentHash)) { + res.status(400).json({ error: 'invalid payment_hash' }); + return; + } + const result = await promotePendingToMessage(paymentHash.toLowerCase()); + res.json(result); + } catch (err) { + console.error('Board confirm error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** Poll payment status (server-side LNbits public API — avoids CORS). */ +router.get('/payment/:paymentHash/status', async (req: Request, res: Response) => { + try { + const raw = req.params.paymentHash; + const paymentHash = Array.isArray(raw) ? raw[0] : raw; + if (!paymentHash || !/^[a-f0-9]{64}$/i.test(paymentHash)) { + res.status(400).json({ error: 'invalid payment_hash' }); + return; + } + const hash = paymentHash.toLowerCase(); + const paid = await getPublicPaymentStatus(hash); + const message = await prisma.boardMessage.findUnique({ + where: { paymentHash: hash }, + select: { id: true }, + }); + res.json({ paid: paid || !!message, messageCreated: !!message }); + } catch (err) { + console.error('Board payment status error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** Public list: active messages only */ +router.get('/', async (_req: Request, res: Response) => { + try { + const messages = await prisma.boardMessage.findMany({ + where: { status: 'active' }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + paymentHash: true, + content: true, + authorName: true, + pubkey: true, + profilePic: true, + satsPaid: true, + likeCount: true, + createdAt: true, + }, + }); + res.json(messages); + } catch (err) { + console.error('List board messages error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** Like (Nostr-attributed messages only; logged-in users) */ +router.post('/:id/like', requireAuth, async (req: Request, res: Response) => { + try { + const rawId = req.params.id; + const id = Array.isArray(rawId) ? rawId[0] : rawId; + const msg = await prisma.boardMessage.findUnique({ where: { id } }); + if (!msg || msg.status !== 'active') { + res.status(404).json({ error: 'Message not found' }); + return; + } + if (!msg.pubkey) { + res.status(400).json({ error: 'Likes are only for Nostr-attributed messages' }); + return; + } + const updated = await prisma.boardMessage.update({ + where: { id: msg.id }, + data: { likeCount: { increment: 1 } }, + select: { likeCount: true }, + }); + res.json({ likeCount: updated.likeCount }); + } catch (err) { + console.error('Board like error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/index.ts b/backend/src/index.ts index fb59ecf..f27e477 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,8 @@ import mediaRouter from './api/media'; import faqsRouter from './api/faqs'; import calendarRouter from './api/calendar'; import nip05Router from './api/nip05'; +import messagesRouter from './api/messages'; +import adminMessagesRouter from './api/adminMessages'; const app = express(); const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10); @@ -48,6 +50,8 @@ app.use('/api/media', mediaRouter); app.use('/api/faqs', faqsRouter); app.use('/api/calendar', calendarRouter); app.use('/api/nip05', nip05Router); +app.use('/api/messages', messagesRouter); +app.use('/api/admin/messages', adminMessagesRouter); app.get('/api/health', (_req, res) => { res.json({ status: 'ok' }); diff --git a/backend/src/services/lnbits.ts b/backend/src/services/lnbits.ts new file mode 100644 index 0000000..2da416b --- /dev/null +++ b/backend/src/services/lnbits.ts @@ -0,0 +1,113 @@ +const LNBITS_URL = (process.env.LNBITS_URL || 'https://legend.lnbits.com').replace(/\/$/, ''); +const LNBITS_API_KEY = process.env.LNBITS_API_KEY || ''; + +export interface CreateInvoiceResult { + payment_hash: string; + payment_request: string; + checking_id: string; +} + +function pickBolt11(data: Record): string { + const pr = data.payment_request ?? data.bolt11; + if (typeof pr === 'string' && pr.length > 0) return pr; + return ''; +} + +export async function createIncomingInvoice(params: { + amountSats: number; + memo: string; + webhookUrl: string; + expirySeconds?: number; +}): Promise { + if (!LNBITS_API_KEY) { + throw new Error('LNBITS_API_KEY is not configured'); + } + + const body: Record = { + out: false, + amount: params.amountSats, + unit: 'sat', + memo: params.memo, + expiry: params.expirySeconds ?? 3600, + webhook: params.webhookUrl, + }; + + const res = await fetch(`${LNBITS_URL}/api/v1/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': LNBITS_API_KEY, + }, + body: JSON.stringify(body), + }); + + const data = (await res.json().catch(() => ({}))) as Record; + + if (!res.ok) { + const detail = + typeof data.detail === 'string' + ? data.detail + : Array.isArray(data.detail) + ? JSON.stringify(data.detail) + : typeof data.message === 'string' + ? data.message + : res.statusText; + throw new Error(`LNbits invoice failed: ${detail || res.status}`); + } + + const payment_hash = typeof data.payment_hash === 'string' ? data.payment_hash : ''; + const payment_request = pickBolt11(data); + const checking_id = + typeof data.checking_id === 'string' && data.checking_id.length > 0 + ? data.checking_id + : payment_hash; + + if (!payment_hash || !payment_request) { + throw new Error('LNbits returned an unexpected invoice payload'); + } + + return { payment_hash, payment_request, checking_id }; +} + +export interface PaymentVerifyResult { + paid: boolean; + amountMsat?: number; +} + +/** Verify invoice belongs to our wallet and is paid (requires API key). */ +export async function verifyIncomingPaymentPaid(paymentHash: string): Promise { + if (!LNBITS_API_KEY) { + throw new Error('LNBITS_API_KEY is not configured'); + } + + const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`, { + headers: { 'X-Api-Key': LNBITS_API_KEY }, + }); + + if (res.status === 404) { + return { paid: false }; + } + + const data = (await res.json().catch(() => ({}))) as Record; + + if (!res.ok) { + return { paid: false }; + } + + const paid = data.paid === true; + let amountMsat: number | undefined; + const details = data.details as Record | undefined; + if (details && typeof details.amount === 'number') { + amountMsat = details.amount; + } + + return { paid, amountMsat }; +} + +/** Public LNbits poll (no key): { paid: boolean } */ +export async function getPublicPaymentStatus(paymentHash: string): Promise { + const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`); + if (!res.ok) return false; + const data = (await res.json().catch(() => ({}))) as Record; + return data.paid === true; +} diff --git a/frontend/app/admin/messages/page.tsx b/frontend/app/admin/messages/page.tsx new file mode 100644 index 0000000..0dff6c3 --- /dev/null +++ b/frontend/app/admin/messages/page.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { formatDate } from "@/lib/utils"; +import { Eye, EyeOff, Trash2 } from "lucide-react"; + +type Row = { + id: string; + paymentHash: string; + content: string; + authorName: string; + pubkey: string | null; + satsPaid: number; + status: string; + likeCount: number; + createdAt: string; +}; + +export default function AdminBoardMessagesPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + try { + const data = await api.getAdminBoardMessages(); + setRows(data); + setError(""); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const toggleHide = async (id: string) => { + try { + await api.hideBoardMessage(id); + await load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Hide failed"); + } + }; + + const softDelete = async (id: string) => { + if (!confirm("Mark this message as deleted? It will disappear from the public board.")) return; + try { + await api.deleteBoardMessage(id); + await load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Delete failed"); + } + }; + + if (loading) { + return ( +
+

Loading board messages…

+
+ ); + } + + return ( +
+

Message board

+

+ Lightning-paid public messages. Hide toggles visibility on the site; delete marks a row as removed + without dropping history. +

+ {error &&

{error}

} + +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
ContentAuthorSatsStatusDateActions
+

{r.content}

+
{r.authorName}{r.satsPaid} + + {r.status} + + + {formatDate(r.createdAt)} + +
+ + +
+
+ {rows.length === 0 && ( +

No board messages yet.

+ )} +
+
+ ); +} diff --git a/frontend/app/board/layout.tsx b/frontend/app/board/layout.tsx new file mode 100644 index 0000000..2cbdea3 --- /dev/null +++ b/frontend/app/board/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Message Board — Pay with Lightning", + description: + "Post a public message on the Belgian Bitcoin Embassy board. Pay a small Lightning invoice via LNbits to publish.", + openGraph: { + title: "Message Board — Belgian Bitcoin Embassy", + description: "Pay with Lightning to post a message.", + }, + alternates: { canonical: "/board" }, +}; + +export default function BoardLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/frontend/app/board/page.tsx b/frontend/app/board/page.tsx new file mode 100644 index 0000000..c4bbe55 --- /dev/null +++ b/frontend/app/board/page.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { QRCodeSVG } from "qrcode.react"; +import { nip19 } from "nostr-tools"; +import { Navbar } from "@/components/public/Navbar"; +import { Footer } from "@/components/public/Footer"; +import { Button } from "@/components/ui/Button"; +import { api } from "@/lib/api"; +import { useAuth } from "@/hooks/useAuth"; +import { formatDate } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import { shortenPubkey } from "@/lib/nostr"; +import { Copy, Check, Heart, Zap } from "lucide-react"; + +type BoardMessage = { + id: string; + paymentHash: string; + content: string; + authorName: string; + pubkey: string | null; + profilePic: string | null; + satsPaid: number; + likeCount: number; + createdAt: string; +}; + +type PayPhase = "idle" | "polling" | "confirming" | "paid"; + +const MAX_LEN = 300; + +function BoardAvatar({ + picture, + name, + size = 40, +}: { + picture?: string | null; + name: string; + size?: number; +}) { + const [err, setErr] = useState(false); + const initial = name.slice(0, 1).toUpperCase() || "?"; + if (picture && !err) { + return ( + setErr(true)} + unoptimized + /> + ); + } + return ( +
+ {initial} +
+ ); +} + +export default function BoardPage() { + const { user } = useAuth(); + const [config, setConfig] = useState<{ + priceSats: number; + bbeZapPubkey: string | null; + bbeZapAddress: string | null; + } | null>(null); + const [messages, setMessages] = useState([]); + const [content, setContent] = useState(""); + const [guestName, setGuestName] = useState(""); + const [postAsAnon, setPostAsAnon] = useState(false); + const [payPhase, setPayPhase] = useState("idle"); + const [payError, setPayError] = useState(""); + const [invoice, setInvoice] = useState<{ pr: string; hash: string } | null>(null); + const [copied, setCopied] = useState(false); + const [localLikes, setLocalLikes] = useState>({}); + const [highlightPaymentHash, setHighlightPaymentHash] = useState(null); + const pollRef = useRef | null>(null); + + const loadMessages = useCallback(async () => { + const list = await api.getBoardMessages(); + setMessages(list as BoardMessage[]); + }, []); + + const loadConfig = useCallback(async () => { + const c = await api.getBoardConfig(); + setConfig(c); + }, []); + + useEffect(() => { + loadConfig().catch(() => {}); + }, [loadConfig]); + + useEffect(() => { + loadMessages().catch(() => {}); + const t = setInterval(() => { + loadMessages().catch(() => {}); + }, 12_000); + return () => clearInterval(t); + }, [loadMessages]); + + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + const displayName = user?.name || user?.displayName || ""; + const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ""; + const shortNpub = npub.length > 20 ? `${npub.slice(0, 14)}…${npub.slice(-12)}` : npub; + + const handlePayAndPost = async () => { + setPayError(""); + const trimmed = content.trim(); + if (!trimmed) { + setPayError("Write a message first."); + return; + } + if (trimmed.length > MAX_LEN) { + setPayError(`Message must be ${MAX_LEN} characters or fewer.`); + return; + } + + try { + const body: Parameters[0] = { content: trimmed }; + if (user?.pubkey && !postAsAnon) { + body.pubkey = user.pubkey; + body.name = displayName || undefined; + body.profilePic = user.picture; + } else { + const n = guestName.trim(); + if (n) body.name = n; + } + body.postAsAnon = !!(user?.pubkey && postAsAnon); + + const inv = await api.createBoardInvoice(body); + setHighlightPaymentHash(inv.payment_hash); + setInvoice({ pr: inv.payment_request, hash: inv.payment_hash }); + setPayPhase("polling"); + + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(async () => { + try { + const status = await api.getBoardPaymentStatus(inv.payment_hash); + if (!status.paid) return; + + // Payment detected — try to ensure the message row exists + setPayPhase("confirming"); + setInvoice(null); + + if (!status.messageCreated) { + // Webhook may not have fired yet; call confirm to create the row + await api.confirmBoardPayment(inv.payment_hash).catch(() => {}); + } + + // Poll until the message actually appears in the list (max ~30s) + let found = false; + for (let attempt = 0; attempt < 15; attempt++) { + const list = await api.getBoardMessages(); + setMessages(list as BoardMessage[]); + if (list.some((m) => m.paymentHash === inv.payment_hash)) { + found = true; + break; + } + // Try confirm again if first attempt didn't work + if (attempt === 2) { + await api.confirmBoardPayment(inv.payment_hash).catch(() => {}); + } + await new Promise((r) => setTimeout(r, 2000)); + } + + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + + if (found) { + setPayPhase("paid"); + setContent(""); + setGuestName(""); + setPostAsAnon(false); + setTimeout(() => { + setPayPhase("idle"); + setHighlightPaymentHash(null); + }, 4000); + } else { + setPayPhase("idle"); + setPayError("Payment received but message is delayed — it will appear shortly."); + } + } catch { + /* keep polling */ + } + }, 2000); + } catch (e: unknown) { + setPayPhase("idle"); + setPayError(e instanceof Error ? e.message : "Could not create invoice."); + } + }; + + const copyPr = async () => { + if (!invoice?.pr) return; + await navigator.clipboard.writeText(invoice.pr); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const bumpLike = async (msg: BoardMessage) => { + if (!user) return; + try { + const { likeCount } = await api.likeBoardMessage(msg.id); + setLocalLikes((prev) => ({ ...prev, [msg.id]: likeCount })); + } catch (e: unknown) { + setPayError(e instanceof Error ? e.message : "Like failed"); + } + }; + + const handleZap = async (msg: BoardMessage) => { + const zapAddress = config?.bbeZapAddress; + const fallbackPub = config?.bbeZapPubkey; + + try { + const w = window as unknown as { + nostr?: { zap?: (args: Record) => Promise }; + }; + if (msg.pubkey && w.nostr?.zap) { + await w.nostr.zap({ + pubkey: msg.pubkey, + amount: 21, + comment: "BBE board zap", + }); + return; + } + } catch { + /* fall through */ + } + + if (msg.pubkey) { + window.open(`https://njump.me/${nip19.npubEncode(msg.pubkey)}`, "_blank", "noopener,noreferrer"); + return; + } + + if (zapAddress) { + const addr = zapAddress.replace(/^lightning:/i, ""); + window.location.href = `lightning:${addr}`; + return; + } + + if (fallbackPub) { + try { + window.open( + `https://njump.me/${nip19.npubEncode(fallbackPub)}`, + "_blank", + "noopener,noreferrer" + ); + } catch { + setPayError("Invalid BOARD_ZAP_PUBKEY on server (expected hex pubkey)."); + } + return; + } + + setPayError("Zap: configure BOARD_ZAP_LN_ADDRESS or BOARD_ZAP_PUBKEY on the server."); + }; + + const len = content.length; + + return ( + <> + +
+
+

Message board

+

+ Pay {config?.priceSats ?? "…"} sats via Lightning to post. Messages are public and moderated. +

+ +
+

Post a message

+ + {user && !postAsAnon ? ( +
+ +
+

{displayName || "Nostr user"}

+

+ {shortNpub} +

+
+
+ ) : ( +
+ {user && ( + + )} + {!user || postAsAnon ? ( +
+ + setGuestName(e.target.value)} + placeholder="Guest" + maxLength={80} + className="w-full rounded-lg border border-outline-variant bg-surface px-3 py-2 text-on-surface" + /> +
+ ) : null} +
+ )} + +