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
This commit is contained in:
16
.env.example
16
.env.example
@@ -4,7 +4,9 @@ ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
|
|||||||
# Nostr relays (comma-separated)
|
# Nostr relays (comma-separated)
|
||||||
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
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"
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
@@ -22,3 +24,15 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
|||||||
NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org
|
NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org
|
||||||
NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy
|
NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy
|
||||||
NEXT_PUBLIC_SITE_TAGLINE=Belgium's Monthly Bitcoin Meetup
|
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=
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:push": "dotenv -e ../.env -- prisma db push",
|
"db:push": "dotenv -e ../.env -e .env -- prisma db push",
|
||||||
"db:seed": "dotenv -e ../.env -- prisma db seed",
|
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
|
||||||
"db:studio": "dotenv -e ../.env -- prisma studio",
|
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
|
||||||
"migrate:deploy": "dotenv -e ../.env -- prisma migrate deploy"
|
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
|||||||
@@ -147,3 +147,27 @@ model Faq {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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())
|
||||||
|
}
|
||||||
|
|||||||
68
backend/src/api/adminMessages.ts
Normal file
68
backend/src/api/adminMessages.ts
Normal file
@@ -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;
|
||||||
359
backend/src/api/messages.ts
Normal file
359
backend/src/api/messages.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -21,6 +21,8 @@ import mediaRouter from './api/media';
|
|||||||
import faqsRouter from './api/faqs';
|
import faqsRouter from './api/faqs';
|
||||||
import calendarRouter from './api/calendar';
|
import calendarRouter from './api/calendar';
|
||||||
import nip05Router from './api/nip05';
|
import nip05Router from './api/nip05';
|
||||||
|
import messagesRouter from './api/messages';
|
||||||
|
import adminMessagesRouter from './api/adminMessages';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
|
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/faqs', faqsRouter);
|
||||||
app.use('/api/calendar', calendarRouter);
|
app.use('/api/calendar', calendarRouter);
|
||||||
app.use('/api/nip05', nip05Router);
|
app.use('/api/nip05', nip05Router);
|
||||||
|
app.use('/api/messages', messagesRouter);
|
||||||
|
app.use('/api/admin/messages', adminMessagesRouter);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
|
|||||||
113
backend/src/services/lnbits.ts
Normal file
113
backend/src/services/lnbits.ts
Normal file
@@ -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, unknown>): 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<CreateInvoiceResult> {
|
||||||
|
if (!LNBITS_API_KEY) {
|
||||||
|
throw new Error('LNBITS_API_KEY is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<PaymentVerifyResult> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { paid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paid = data.paid === true;
|
||||||
|
let amountMsat: number | undefined;
|
||||||
|
const details = data.details as Record<string, unknown> | 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<boolean> {
|
||||||
|
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`);
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
return data.paid === true;
|
||||||
|
}
|
||||||
146
frontend/app/admin/messages/page.tsx
Normal file
146
frontend/app/admin/messages/page.tsx
Normal file
@@ -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<Row[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
|
<p className="text-on-surface/50">Loading board messages…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-on-surface">Message board</h1>
|
||||||
|
<p className="text-on-surface-variant text-sm max-w-2xl">
|
||||||
|
Lightning-paid public messages. Hide toggles visibility on the site; delete marks a row as removed
|
||||||
|
without dropping history.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-outline-variant/30">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-surface-container-high text-on-surface-variant uppercase text-xs">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-semibold">Content</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Author</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Sats</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Status</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Date</th>
|
||||||
|
<th className="px-4 py-3 font-semibold w-40">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-outline-variant/20">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="bg-surface-container-low hover:bg-surface-container/80">
|
||||||
|
<td className="px-4 py-3 max-w-md">
|
||||||
|
<p className="text-on-surface line-clamp-3 whitespace-pre-wrap break-words">{r.content}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-on-surface whitespace-nowrap">{r.authorName}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-primary">{r.satsPaid}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
r.status === "active"
|
||||||
|
? "text-green-600 font-medium"
|
||||||
|
: r.status === "hidden"
|
||||||
|
? "text-amber-600 font-medium"
|
||||||
|
: "text-on-surface-variant"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-on-surface-variant whitespace-nowrap">
|
||||||
|
{formatDate(r.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleHide(r.id)}
|
||||||
|
disabled={r.status === "deleted"}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-surface-container-high text-on-surface text-xs font-medium hover:bg-surface-container disabled:opacity-40"
|
||||||
|
title={r.status === "hidden" ? "Unhide" : "Hide"}
|
||||||
|
>
|
||||||
|
{r.status === "hidden" ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||||
|
{r.status === "hidden" ? "Unhide" : "Hide"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => softDelete(r.id)}
|
||||||
|
disabled={r.status === "deleted"}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-error/15 text-error text-xs font-medium hover:bg-error/25 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="p-8 text-center text-on-surface-variant">No board messages yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/app/board/layout.tsx
Normal file
16
frontend/app/board/layout.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
449
frontend/app/board/page.tsx
Normal file
449
frontend/app/board/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Image
|
||||||
|
src={picture}
|
||||||
|
alt=""
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="rounded-full object-cover shrink-0"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
onError={() => setErr(true)}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm shrink-0"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BoardMessage[]>([]);
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [guestName, setGuestName] = useState("");
|
||||||
|
const [postAsAnon, setPostAsAnon] = useState(false);
|
||||||
|
const [payPhase, setPayPhase] = useState<PayPhase>("idle");
|
||||||
|
const [payError, setPayError] = useState("");
|
||||||
|
const [invoice, setInvoice] = useState<{ pr: string; hash: string } | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [localLikes, setLocalLikes] = useState<Record<string, number>>({});
|
||||||
|
const [highlightPaymentHash, setHighlightPaymentHash] = useState<string | null>(null);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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<typeof api.createBoardInvoice>[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<string, unknown>) => Promise<unknown> };
|
||||||
|
};
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<div className="min-h-screen pb-20">
|
||||||
|
<div className="max-w-3xl mx-auto px-8 pt-16 pb-10">
|
||||||
|
<h1 className="text-4xl font-black mb-2">Message board</h1>
|
||||||
|
<p className="text-on-surface-variant text-lg mb-10">
|
||||||
|
Pay {config?.priceSats ?? "…"} sats via Lightning to post. Messages are public and moderated.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-outline-variant/30 bg-surface-container-low p-6 mb-12">
|
||||||
|
<h2 className="text-lg font-bold text-on-surface mb-4">Post a message</h2>
|
||||||
|
|
||||||
|
{user && !postAsAnon ? (
|
||||||
|
<div className="flex items-center gap-3 mb-4 p-3 rounded-xl bg-surface-container">
|
||||||
|
<BoardAvatar picture={user.picture} name={displayName || "You"} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-on-surface truncate">{displayName || "Nostr user"}</p>
|
||||||
|
<p className="text-xs font-mono text-on-surface-variant truncate" title={npub}>
|
||||||
|
{shortNpub}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{user && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-on-surface-variant cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={postAsAnon}
|
||||||
|
onChange={(e) => setPostAsAnon(e.target.checked)}
|
||||||
|
className="rounded border-outline-variant"
|
||||||
|
/>
|
||||||
|
Post as anon (hide Nostr profile)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{!user || postAsAnon ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-on-surface-variant mb-1">
|
||||||
|
Name (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={guestName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value.slice(0, MAX_LEN))}
|
||||||
|
placeholder="What’s on your mind?"
|
||||||
|
rows={4}
|
||||||
|
maxLength={MAX_LEN}
|
||||||
|
className="w-full rounded-xl border border-outline-variant bg-surface px-4 py-3 text-on-surface placeholder:text-on-surface-variant/50 resize-y min-h-[120px]"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center mt-2 text-sm text-on-surface-variant">
|
||||||
|
<span>
|
||||||
|
{len}/{MAX_LEN}
|
||||||
|
</span>
|
||||||
|
{payPhase === "polling" && (
|
||||||
|
<span className="text-primary font-medium animate-pulse">Awaiting payment…</span>
|
||||||
|
)}
|
||||||
|
{payPhase === "confirming" && (
|
||||||
|
<span className="text-primary font-medium animate-pulse">Payment received — publishing…</span>
|
||||||
|
)}
|
||||||
|
{payPhase === "paid" && <span className="text-green-500 font-medium">Posted!</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payError && <p className="text-error text-sm mt-3">{payError}</p>}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handlePayAndPost}
|
||||||
|
disabled={payPhase === "polling" || payPhase === "confirming" || !content.trim()}
|
||||||
|
>
|
||||||
|
Pay & post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice && payPhase === "polling" && (
|
||||||
|
<div className="mt-8 p-4 rounded-xl bg-surface-container border border-outline-variant/40">
|
||||||
|
<p className="text-sm text-on-surface-variant mb-3">Scan or copy the Lightning invoice:</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6 items-start">
|
||||||
|
<div className="bg-white p-3 rounded-lg shrink-0">
|
||||||
|
<QRCodeSVG value={invoice.pr} size={180} level="M" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyPr}
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
{copied ? "Copied" : "Copy invoice"}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs font-mono break-all text-on-surface-variant max-h-32 overflow-y-auto">
|
||||||
|
{invoice.pr}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold mb-4">Messages</h2>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{messages.map((m) => {
|
||||||
|
const likes = localLikes[m.id] ?? m.likeCount;
|
||||||
|
const isNew = highlightPaymentHash && m.paymentHash === highlightPaymentHash;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border p-5 transition-shadow duration-500",
|
||||||
|
isNew
|
||||||
|
? "border-primary bg-primary-container/10 shadow-lg shadow-primary/20"
|
||||||
|
: "border-outline-variant/30 bg-surface-container-low"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<BoardAvatar picture={m.profilePic} name={m.authorName} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-2 mb-1">
|
||||||
|
<span className="font-bold text-on-surface">{m.authorName}</span>
|
||||||
|
<span className="text-xs text-on-surface-variant">{formatDate(m.createdAt)}</span>
|
||||||
|
<span className="text-xs font-mono text-primary">⚡ {m.satsPaid} sats</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-on-surface whitespace-pre-wrap break-words">{m.content}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
|
{m.pubkey && user ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bumpLike(m)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
|
||||||
|
>
|
||||||
|
<Heart size={16} className="text-red-400" />
|
||||||
|
{likes}
|
||||||
|
</button>
|
||||||
|
) : m.pubkey ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface-variant">
|
||||||
|
<Heart size={16} className="text-red-400/70" />
|
||||||
|
{likes}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleZap(m)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
|
||||||
|
>
|
||||||
|
<Zap size={16} className="text-amber-400" />
|
||||||
|
Zap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="text-on-surface-variant text-center py-12">No messages yet — be the first.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Inbox,
|
Inbox,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -28,6 +29,7 @@ const navItems = [
|
|||||||
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
|
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
|
||||||
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
|
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
|
||||||
|
{ href: "/admin/messages", label: "Board", icon: MessageSquare, adminOnly: false },
|
||||||
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
|
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
|
||||||
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
|
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
|
||||||
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
|
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const SECTION_LINKS = [{ label: "About", anchor: "about" }];
|
|||||||
|
|
||||||
const PAGE_LINKS = [
|
const PAGE_LINKS = [
|
||||||
{ label: "Meetups", href: "/events" },
|
{ label: "Meetups", href: "/events" },
|
||||||
|
{ label: "Board", href: "/board" },
|
||||||
{ label: "Community", href: "/community" },
|
{ label: "Community", href: "/community" },
|
||||||
{ label: "FAQ", href: "/faq" },
|
{ label: "FAQ", href: "/faq" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -183,4 +183,51 @@ export const api = {
|
|||||||
},
|
},
|
||||||
reviewSubmission: (id: string, data: { status: string; reviewNote?: string }) =>
|
reviewSubmission: (id: string, data: { status: string; reviewNote?: string }) =>
|
||||||
request<any>(`/submissions/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
request<any>(`/submissions/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Message board (Lightning)
|
||||||
|
getBoardConfig: () =>
|
||||||
|
request<{ priceSats: number; bbeZapPubkey: string | null; bbeZapAddress: string | null }>(
|
||||||
|
"/messages/config"
|
||||||
|
),
|
||||||
|
createBoardInvoice: (data: {
|
||||||
|
content: string;
|
||||||
|
name?: string;
|
||||||
|
pubkey?: string;
|
||||||
|
profilePic?: string;
|
||||||
|
postAsAnon?: boolean;
|
||||||
|
}) =>
|
||||||
|
request<{ payment_request: string; checking_id: string; payment_hash: string }>(
|
||||||
|
"/messages/invoice",
|
||||||
|
{ method: "POST", body: JSON.stringify(data) }
|
||||||
|
),
|
||||||
|
getBoardPaymentStatus: (paymentHash: string) =>
|
||||||
|
request<{ paid: boolean; messageCreated: boolean }>(
|
||||||
|
`/messages/payment/${encodeURIComponent(paymentHash)}/status`
|
||||||
|
),
|
||||||
|
confirmBoardPayment: (paymentHash: string) =>
|
||||||
|
request<{ ok: boolean; duplicate?: boolean; ignored?: string }>(
|
||||||
|
`/messages/confirm/${encodeURIComponent(paymentHash)}`,
|
||||||
|
{ method: "POST" }
|
||||||
|
),
|
||||||
|
getBoardMessages: () =>
|
||||||
|
request<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
paymentHash: string;
|
||||||
|
content: string;
|
||||||
|
authorName: string;
|
||||||
|
pubkey: string | null;
|
||||||
|
profilePic: string | null;
|
||||||
|
satsPaid: number;
|
||||||
|
likeCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}>
|
||||||
|
>("/messages"),
|
||||||
|
likeBoardMessage: (id: string) =>
|
||||||
|
request<{ likeCount: number }>(`/messages/${id}/like`, { method: "POST" }),
|
||||||
|
getAdminBoardMessages: () => request<any[]>("/admin/messages"),
|
||||||
|
hideBoardMessage: (id: string) =>
|
||||||
|
request<any>(`/admin/messages/${id}/hide`, { method: "POST" }),
|
||||||
|
deleteBoardMessage: (id: string) =>
|
||||||
|
request<any>(`/admin/messages/${id}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|||||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "belgian-bitcoin-embassy",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"db:push": "npm run db:push --prefix backend",
|
||||||
|
"db:seed": "npm run db:seed --prefix backend",
|
||||||
|
"db:studio": "npm run db:studio --prefix backend"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user