first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

53
backend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
const router = Router();
router.post('/challenge', async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey || typeof pubkey !== 'string') {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const challenge = authService.createChallenge(pubkey);
res.json({ challenge });
} catch (err) {
console.error('Challenge error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/verify', async (req: Request, res: Response) => {
try {
const { pubkey, signedEvent } = req.body;
if (!pubkey || !signedEvent) {
res.status(400).json({ error: 'pubkey and signedEvent are required' });
return;
}
const valid = authService.verifySignature(pubkey, signedEvent);
if (!valid) {
res.status(401).json({ error: 'Invalid signature or expired challenge' });
return;
}
const role = await authService.getRole(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
update: { role },
create: { pubkey, role },
});
const token = authService.generateToken(pubkey, role);
res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } });
} catch (err) {
console.error('Verify error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

163
backend/src/api/calendar.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
const router = Router();
function escapeIcs(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
// ICS lines must be folded at 75 octets (RFC 5545 §3.1)
function fold(line: string): string {
const MAX = 75;
if (line.length <= MAX) return line;
let out = '';
let pos = 0;
while (pos < line.length) {
if (pos === 0) {
out += line.slice(0, MAX);
pos = MAX;
} else {
out += '\r\n ' + line.slice(pos, pos + MAX - 1);
pos += MAX - 1;
}
}
return out;
}
function toIcsDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return (
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
`T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`
);
}
// Parse "HH:MM", "H:MM am/pm", "Hpm" etc.
function parseLocalTime(t: string): { h: number; m: number } {
const clean = t.trim();
const m24 = clean.match(/^(\d{1,2}):(\d{2})$/);
if (m24) return { h: parseInt(m24[1]), m: parseInt(m24[2]) };
const mAp = clean.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
if (mAp) {
let h = parseInt(mAp[1]);
const m = mAp[2] ? parseInt(mAp[2]) : 0;
if (mAp[3].toLowerCase() === 'pm' && h !== 12) h += 12;
if (mAp[3].toLowerCase() === 'am' && h === 12) h = 0;
return { h, m };
}
return { h: 18, m: 0 };
}
// Brussels is UTC+1 (CET) / UTC+2 (CEST). Use +1 as conservative default.
const BRUSSELS_OFFSET_HOURS = 1;
function parseEventDates(
dateStr: string,
timeStr: string
): { start: Date; end: Date } {
const [year, month, day] = dateStr.split('-').map(Number);
const parts = timeStr.split(/\s*[-]\s*/);
const { h: startH, m: startM } = parseLocalTime(parts[0]);
// Convert local Brussels time to UTC
const utcStartH = startH - BRUSSELS_OFFSET_HOURS;
const start = new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
let end: Date;
if (parts[1]) {
const { h: endH, m: endM } = parseLocalTime(parts[1]);
const utcEndH = endH - BRUSSELS_OFFSET_HOURS;
end = new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0));
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
} else {
end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
}
return { start, end };
}
router.get('/ics', async (_req: Request, res: Response) => {
try {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
orderBy: { date: 'asc' },
});
const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace(
/\/$/,
''
);
const now = new Date();
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Belgian Bitcoin Embassy//Events//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
fold('X-WR-CALNAME:Belgian Bitcoin Embassy Events'),
fold('X-WR-CALDESC:Upcoming meetups and events by the Belgian Bitcoin Embassy'),
'X-WR-TIMEZONE:Europe/Brussels',
];
for (const meetup of meetups) {
try {
const { start, end } = parseEventDates(meetup.date, meetup.time);
const eventUrl = meetup.link || `${siteUrl}/events/${meetup.id}`;
lines.push('BEGIN:VEVENT');
lines.push(fold(`UID:${meetup.id}@belgianbitcoinembassy.org`));
lines.push(`DTSTAMP:${toIcsDate(now)}`);
lines.push(`DTSTART:${toIcsDate(start)}`);
lines.push(`DTEND:${toIcsDate(end)}`);
lines.push(fold(`SUMMARY:${escapeIcs(meetup.title)}`));
if (meetup.description) {
lines.push(fold(`DESCRIPTION:${escapeIcs(meetup.description)}`));
}
if (meetup.location) {
lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`));
}
lines.push(fold(`URL:${eventUrl}`));
lines.push(
'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org'
);
// 15-minute reminder alarm
lines.push('BEGIN:VALARM');
lines.push('TRIGGER:-PT15M');
lines.push('ACTION:DISPLAY');
lines.push(fold(`DESCRIPTION:Reminder: ${escapeIcs(meetup.title)}`));
lines.push('END:VALARM');
lines.push('END:VEVENT');
} catch {
// Skip events with unparseable dates
}
}
lines.push('END:VCALENDAR');
const icsBody = lines.join('\r\n') + '\r\n';
res.set({
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'inline; filename="bbe-events.ics"',
'Cache-Control': 'public, max-age=300',
});
res.send(icsBody);
} catch (err) {
console.error('Calendar ICS error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
try {
const categories = await prisma.category.findMany({
orderBy: { sortOrder: 'asc' },
});
res.json(categories);
} catch (err) {
console.error('List categories error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { name, slug, sortOrder } = req.body;
if (!name || !slug) {
res.status(400).json({ error: 'name and slug are required' });
return;
}
const category = await prisma.category.create({
data: {
name,
slug,
sortOrder: sortOrder || 0,
},
});
res.status(201).json(category);
} catch (err) {
console.error('Create category error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
where: { id: req.params.id as string },
});
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
const { name, slug, sortOrder } = req.body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
const updated = await prisma.category.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update category error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
where: { id: req.params.id as string },
});
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
await prisma.category.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete category error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

155
backend/src/api/faqs.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
// Public: get FAQs (homepage-visible only by default; pass ?all=true for all)
router.get('/', async (req: Request, res: Response) => {
try {
const showAll = req.query.all === 'true';
const faqs = await prisma.faq.findMany({
where: showAll ? undefined : { showOnHomepage: true },
orderBy: { order: 'asc' },
});
res.json(faqs);
} catch (err) {
console.error('List public FAQs error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin: get all FAQs regardless of visibility
router.get(
'/all',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (_req: Request, res: Response) => {
try {
const faqs = await prisma.faq.findMany({
orderBy: { order: 'asc' },
});
res.json(faqs);
} catch (err) {
console.error('List all FAQs error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: create FAQ
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { question, answer, showOnHomepage } = req.body;
if (!question || !answer) {
res.status(400).json({ error: 'question and answer are required' });
return;
}
const maxOrder = await prisma.faq.aggregate({ _max: { order: true } });
const nextOrder = (maxOrder._max.order ?? -1) + 1;
const faq = await prisma.faq.create({
data: {
question,
answer,
order: nextOrder,
showOnHomepage: showOnHomepage !== undefined ? showOnHomepage : true,
},
});
res.status(201).json(faq);
} catch (err) {
console.error('Create FAQ error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: update FAQ
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
if (!faq) {
res.status(404).json({ error: 'FAQ not found' });
return;
}
const { question, answer, showOnHomepage } = req.body;
const updateData: any = {};
if (question !== undefined) updateData.question = question;
if (answer !== undefined) updateData.answer = answer;
if (showOnHomepage !== undefined) updateData.showOnHomepage = showOnHomepage;
const updated = await prisma.faq.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update FAQ error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: delete FAQ
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
if (!faq) {
res.status(404).json({ error: 'FAQ not found' });
return;
}
await prisma.faq.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete FAQ error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: reorder FAQs — accepts array of { id, order }
router.post(
'/reorder',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { items } = req.body as { items: { id: string; order: number }[] };
if (!Array.isArray(items)) {
res.status(400).json({ error: 'items array is required' });
return;
}
await Promise.all(
items.map(({ id, order }) =>
prisma.faq.update({ where: { id }, data: { order } })
)
);
res.json({ success: true });
} catch (err) {
console.error('Reorder FAQs error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

217
backend/src/api/media.ts Normal file
View File

@@ -0,0 +1,217 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { ulid } from 'ulid';
import slugify from 'slugify';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
|| path.resolve(__dirname, '../../../storage/media');
function ensureStorageDir() {
fs.mkdirSync(STORAGE_PATH, { recursive: true });
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
});
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
function getMediaType(mimeType: string): 'image' | 'video' | null {
if (IMAGE_MIMES.includes(mimeType)) return 'image';
if (VIDEO_MIMES.includes(mimeType)) return 'video';
return null;
}
function makeSlug(filename: string): string {
const name = path.parse(filename).name;
return slugify(name, { lower: true, strict: true }) || 'media';
}
const router = Router();
router.post(
'/upload',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
upload.single('file'),
async (req: Request, res: Response) => {
try {
const file = req.file;
if (!file) {
res.status(400).json({ error: 'No file provided' });
return;
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
return;
}
const mediaType = getMediaType(file.mimetype);
if (!mediaType) {
res.status(400).json({ error: 'Could not determine media type' });
return;
}
const id = ulid();
const slug = makeSlug(file.originalname);
ensureStorageDir();
const filePath = path.join(STORAGE_PATH, id);
fs.writeFileSync(filePath, file.buffer);
const metaPath = path.join(STORAGE_PATH, `${id}.json`);
fs.writeFileSync(metaPath, JSON.stringify({
mimeType: file.mimetype,
type: mediaType,
size: file.size,
}));
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
} catch (err) {
console.error('Upload media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get('/', async (_req: Request, res: Response) => {
try {
const media = await prisma.media.findMany({
orderBy: { createdAt: 'desc' },
});
const result = media.map((m) => ({
...m,
url: `/media/${m.id}`,
}));
res.json(result);
} catch (err) {
console.error('List media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:id', async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
where: { id: req.params.id as string },
});
if (!media) {
res.status(404).json({ error: 'Media not found' });
return;
}
res.json({ ...media, url: `/media/${media.id}` });
} catch (err) {
console.error('Get media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
where: { id: req.params.id as string },
});
if (!media) {
res.status(404).json({ error: 'Media not found' });
return;
}
const { title, description, altText } = req.body;
const updateData: any = {};
if (title !== undefined) {
updateData.title = title || null;
updateData.slug = title ? makeSlug(title) : media.slug;
}
if (description !== undefined) updateData.description = description || null;
if (altText !== undefined) updateData.altText = altText || null;
const updated = await prisma.media.update({
where: { id: media.id },
data: updateData,
});
res.json({ ...updated, url: `/media/${updated.id}` });
} catch (err) {
console.error('Update media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
where: { id: req.params.id as string },
});
if (!media) {
res.status(404).json({ error: 'Media not found' });
return;
}
const filePath = path.join(STORAGE_PATH, media.id);
const metaPath = path.join(STORAGE_PATH, `${media.id}.json`);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
// Clean up any cached resized versions
const cachePath = path.join(STORAGE_PATH, 'cache');
if (fs.existsSync(cachePath)) {
const cached = fs.readdirSync(cachePath)
.filter((f) => f.startsWith(media.id));
for (const f of cached) {
fs.unlinkSync(path.join(cachePath, f));
}
}
await prisma.media.delete({ where: { id: media.id } });
res.json({ success: true });
} catch (err) {
console.error('Delete media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

253
backend/src/api/meetups.ts Normal file
View File

@@ -0,0 +1,253 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
const num = parseInt(match[2], 10);
return `${match[1]}${num + 1}${match[3]}`;
}
return `${title} (copy)`;
}
router.get('/', async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true';
const where: any = {};
if (status) where.status = status;
if (!admin) where.visibility = 'PUBLIC';
const meetups = await prisma.meetup.findMany({
where,
orderBy: { date: 'asc' },
});
res.json(meetups);
} catch (err) {
console.error('List meetups error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:id', async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
});
if (!meetup) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
if (!title || !description || !date || !time || !location) {
res
.status(400)
.json({ error: 'title, description, date, time, and location are required' });
return;
}
const meetup = await prisma.meetup.create({
data: {
title,
description,
date,
time,
location,
link: link || null,
imageId: imageId || null,
status: status || 'DRAFT',
featured: featured || false,
visibility: visibility || 'PUBLIC',
},
});
res.status(201).json(meetup);
} catch (err) {
console.error('Create meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/bulk',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { action, ids } = req.body as { action: string; ids: string[] };
if (!action || !Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'action and ids are required' });
return;
}
if (action === 'delete') {
await prisma.meetup.deleteMany({ where: { id: { in: ids } } });
res.json({ success: true, affected: ids.length });
return;
}
if (action === 'publish') {
await prisma.meetup.updateMany({
where: { id: { in: ids } },
data: { status: 'PUBLISHED' },
});
res.json({ success: true, affected: ids.length });
return;
}
if (action === 'duplicate') {
const originals = await prisma.meetup.findMany({ where: { id: { in: ids } } });
const created = await Promise.all(
originals.map((m) =>
prisma.meetup.create({
data: {
title: incrementTitle(m.title),
description: m.description,
date: '',
time: '',
location: m.location,
link: m.link || null,
imageId: m.imageId || null,
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
},
})
)
);
res.json(created);
return;
}
res.status(400).json({ error: 'Unknown action' });
} catch (err) {
console.error('Bulk meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/:id/duplicate',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } });
if (!original) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
const duplicate = await prisma.meetup.create({
data: {
title: incrementTitle(original.title),
description: original.description,
date: '',
time: '',
location: original.location,
link: original.link || null,
imageId: original.imageId || null,
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
},
});
res.status(201).json(duplicate);
} catch (err) {
console.error('Duplicate meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
});
if (!meetup) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (date !== undefined) updateData.date = date;
if (time !== undefined) updateData.time = time;
if (location !== undefined) updateData.location = location;
if (link !== undefined) updateData.link = link;
if (status !== undefined) updateData.status = status;
if (featured !== undefined) updateData.featured = featured;
if (imageId !== undefined) updateData.imageId = imageId;
if (visibility !== undefined) updateData.visibility = visibility;
const updated = await prisma.meetup.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
});
if (!meetup) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
await prisma.meetup.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,143 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get(
'/hidden',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (_req: Request, res: Response) => {
try {
const hidden = await prisma.hiddenContent.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(hidden);
} catch (err) {
console.error('List hidden content error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/hide',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { nostrEventId, reason } = req.body;
if (!nostrEventId) {
res.status(400).json({ error: 'nostrEventId is required' });
return;
}
const hidden = await prisma.hiddenContent.create({
data: {
nostrEventId,
reason: reason || null,
hiddenBy: req.user!.pubkey,
},
});
res.status(201).json(hidden);
} catch (err) {
console.error('Hide content error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/unhide/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const item = await prisma.hiddenContent.findUnique({
where: { id: req.params.id as string },
});
if (!item) {
res.status(404).json({ error: 'Hidden content not found' });
return;
}
await prisma.hiddenContent.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Unhide content error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/blocked',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (_req: Request, res: Response) => {
try {
const blocked = await prisma.blockedPubkey.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(blocked);
} catch (err) {
console.error('List blocked pubkeys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/block',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { pubkey, reason } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const blocked = await prisma.blockedPubkey.create({
data: {
pubkey,
reason: reason || null,
blockedBy: req.user!.pubkey,
},
});
res.status(201).json(blocked);
} catch (err) {
console.error('Block pubkey error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/unblock/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const item = await prisma.blockedPubkey.findUnique({
where: { id: req.params.id as string },
});
if (!item) {
res.status(404).json({ error: 'Blocked pubkey not found' });
return;
}
await prisma.blockedPubkey.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Unblock pubkey error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

32
backend/src/api/nip05.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
try {
const nameFilter = req.query.name as string | undefined;
const where = nameFilter
? { username: nameFilter.toLowerCase() }
: { username: { not: null } };
const users = await prisma.user.findMany({ where: where as any });
const names: Record<string, string> = {};
for (const user of users) {
if (user.username) {
names[user.username] = user.pubkey;
}
}
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.json({ names });
} catch (err) {
console.error('NIP-05 error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

88
backend/src/api/nostr.ts Normal file
View File

@@ -0,0 +1,88 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
router.post(
'/fetch',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
if (!eventId && !naddr) {
res.status(400).json({ error: 'eventId or naddr is required' });
return;
}
let event = null;
if (naddr) {
event = await nostrService.fetchLongformEvent(naddr);
} else if (eventId) {
event = await nostrService.fetchEvent(eventId);
}
if (!event) {
res.status(404).json({ error: 'Event not found on relays' });
return;
}
res.json(event);
} catch (err) {
console.error('Fetch event error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/cache/refresh',
requireAuth,
requireRole(['ADMIN']),
async (_req: Request, res: Response) => {
try {
const cachedEvents = await prisma.nostrEventCache.findMany();
let refreshed = 0;
for (const cached of cachedEvents) {
const event = await nostrService.fetchEvent(cached.eventId, true);
if (event) refreshed++;
}
res.json({ refreshed, total: cachedEvents.length });
} catch (err) {
console.error('Cache refresh error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/debug/:eventId',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const cached = await prisma.nostrEventCache.findUnique({
where: { eventId: req.params.eventId as string },
});
if (!cached) {
res.status(404).json({ error: 'Event not found in cache' });
return;
}
res.json({
...cached,
tags: JSON.parse(cached.tags),
});
} catch (err) {
console.error('Debug event error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

243
backend/src/api/posts.ts Normal file
View File

@@ -0,0 +1,243 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const category = req.query.category as string | undefined;
const skip = (page - 1) * limit;
const where: any = { visible: true };
if (category) {
where.categories = {
some: { category: { slug: category } },
};
}
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
include: {
categories: { include: { category: true } },
},
orderBy: { publishedAt: 'desc' },
skip,
take: limit,
}),
prisma.post.count({ where }),
]);
res.json({
posts,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (err) {
console.error('List posts error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:slug', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({
where: { slug: req.params.slug as string },
include: {
categories: { include: { category: true } },
},
});
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
res.json(post);
} catch (err) {
console.error('Get post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/import',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
if (!eventId && !naddr) {
res.status(400).json({ error: 'eventId or naddr is required' });
return;
}
let event: any = null;
if (naddr) {
event = await nostrService.fetchLongformEvent(naddr);
} else if (eventId) {
event = await nostrService.fetchEvent(eventId);
}
if (!event) {
res.status(404).json({ error: 'Event not found on relays' });
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === 'title');
const title = titleTag?.[1] || 'Untitled';
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${event.id.slice(0, 8)}`;
const excerpt = event.content.slice(0, 200).replace(/[#*_\n]/g, '').trim();
const post = await prisma.post.upsert({
where: { nostrEventId: event.id },
update: {
title,
content: event.content,
excerpt,
},
create: {
nostrEventId: event.id,
title,
slug,
content: event.content,
excerpt,
authorPubkey: event.pubkey,
publishedAt: new Date(event.created_at * 1000),
},
});
res.json(post);
} catch (err) {
console.error('Import post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { title, slug, excerpt, featured, visible, categories } = req.body;
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (slug !== undefined) updateData.slug = slug;
if (excerpt !== undefined) updateData.excerpt = excerpt;
if (featured !== undefined) updateData.featured = featured;
if (visible !== undefined) updateData.visible = visible;
const updated = await prisma.post.update({
where: { id: req.params.id as string },
data: updateData,
});
if (categories && Array.isArray(categories)) {
await prisma.postCategory.deleteMany({
where: { postId: post.id },
});
await prisma.postCategory.createMany({
data: categories.map((categoryId: string) => ({
postId: post.id,
categoryId,
})),
});
}
const result = await prisma.post.findUnique({
where: { id: updated.id },
include: { categories: { include: { category: true } } },
});
res.json(result);
} catch (err) {
console.error('Update post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get('/:slug/reactions', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
const reactions = await nostrService.fetchReactions(post.nostrEventId);
res.json({ count: reactions.length, reactions });
} catch (err) {
console.error('Get reactions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:slug/replies', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
nostrService.fetchReplies(post.nostrEventId),
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
]);
const hiddenIds = new Set(hiddenContent.map((h) => h.nostrEventId));
const blockedKeys = new Set(blockedPubkeys.map((b) => b.pubkey));
const filtered = replies.filter(
(r) => !hiddenIds.has(r.id) && !blockedKeys.has(r.pubkey)
);
res.json({ count: filtered.length, replies: filtered });
} catch (err) {
console.error('Get replies error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
await prisma.post.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

141
backend/src/api/relays.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Router, Request, Response } from 'express';
import { SimplePool } from 'nostr-tools';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
orderBy: { priority: 'asc' },
});
res.json(relays);
} catch (err) {
console.error('List relays error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { url, priority } = req.body;
if (!url) {
res.status(400).json({ error: 'url is required' });
return;
}
const relay = await prisma.relay.create({
data: {
url,
priority: priority || 0,
},
});
res.status(201).json(relay);
} catch (err) {
console.error('Create relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
where: { id: req.params.id as string },
});
if (!relay) {
res.status(404).json({ error: 'Relay not found' });
return;
}
const { url, priority, active } = req.body;
const updateData: any = {};
if (url !== undefined) updateData.url = url;
if (priority !== undefined) updateData.priority = priority;
if (active !== undefined) updateData.active = active;
const updated = await prisma.relay.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
where: { id: req.params.id as string },
});
if (!relay) {
res.status(404).json({ error: 'Relay not found' });
return;
}
await prisma.relay.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/:id/test',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
where: { id: req.params.id as string },
});
if (!relay) {
res.status(404).json({ error: 'Relay not found' });
return;
}
const pool = new SimplePool();
const startTime = Date.now();
try {
await pool.get([relay.url], { kinds: [1], limit: 1 });
const latency = Date.now() - startTime;
res.json({ success: true, latency, url: relay.url });
} catch {
res.json({ success: false, error: 'Connection failed', url: relay.url });
} finally {
pool.close([relay.url]);
}
} catch (err) {
console.error('Test relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,79 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
const PUBLIC_SETTINGS = [
'site_title',
'site_tagline',
'telegram_link',
'nostr_link',
'x_link',
'youtube_link',
'discord_link',
'linkedin_link',
];
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany();
const result: Record<string, string> = {};
for (const s of settings) {
result[s.key] = s.value;
}
res.json(result);
} catch (err) {
console.error('List settings error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get('/public', async (_req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany({
where: { key: { in: PUBLIC_SETTINGS } },
});
const result: Record<string, string> = {};
for (const s of settings) {
result[s.key] = s.value;
}
res.json(result);
} catch (err) {
console.error('Public settings error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.patch(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { key, value } = req.body;
if (!key || value === undefined) {
res.status(400).json({ error: 'key and value are required' });
return;
}
const setting = await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value },
});
res.json(setting);
} catch (err) {
console.error('Update setting error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,114 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.post(
'/',
requireAuth,
async (req: Request, res: Response) => {
try {
const { eventId, naddr, title } = req.body;
if (!title || (!eventId && !naddr)) {
res.status(400).json({ error: 'title and either eventId or naddr are required' });
return;
}
const submission = await prisma.submission.create({
data: {
eventId: eventId || null,
naddr: naddr || null,
title,
authorPubkey: req.user!.pubkey,
},
});
res.status(201).json(submission);
} catch (err) {
console.error('Create submission error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/mine',
requireAuth,
async (req: Request, res: Response) => {
try {
const submissions = await prisma.submission.findMany({
where: { authorPubkey: req.user!.pubkey },
orderBy: { createdAt: 'desc' },
});
res.json(submissions);
} catch (err) {
console.error('List own submissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const where: any = {};
if (status) where.status = status;
const submissions = await prisma.submission.findMany({
where,
orderBy: { createdAt: 'desc' },
});
res.json(submissions);
} catch (err) {
console.error('List submissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { status, reviewNote } = req.body;
if (!status || !['APPROVED', 'REJECTED'].includes(status)) {
res.status(400).json({ error: 'status must be APPROVED or REJECTED' });
return;
}
const submission = await prisma.submission.findUnique({
where: { id: req.params.id as string },
});
if (!submission) {
res.status(404).json({ error: 'Submission not found' });
return;
}
const updated = await prisma.submission.update({
where: { id: req.params.id as string },
data: {
status,
reviewedBy: req.user!.pubkey,
reviewNote: reviewNote || null,
},
});
res.json(updated);
} catch (err) {
console.error('Review submission error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

177
backend/src/api/users.ts Normal file
View File

@@ -0,0 +1,177 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import fs from 'fs';
import path from 'path';
const BLOCKED_USERNAMES_PATH = path.resolve(__dirname, '../../config/blocked-usernames.txt');
function getBlockedUsernames(): Set<string> {
try {
const content = fs.readFileSync(BLOCKED_USERNAMES_PATH, 'utf-8');
return new Set(
content
.split('\n')
.map((l) => l.trim().toLowerCase())
.filter(Boolean)
);
} catch {
return new Set();
}
}
const USERNAME_REGEX = /^[a-z0-9._-]+$/i;
function validateUsername(username: string): string | null {
if (!username || username.trim().length === 0) return 'Username is required';
if (username.length > 50) return 'Username must be 50 characters or fewer';
if (!USERNAME_REGEX.test(username)) return 'Username may only contain letters, numbers, dots, hyphens, and underscores';
const blocked = getBlockedUsernames();
if (blocked.has(username.toLowerCase())) return 'This username is reserved';
return null;
}
const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
async (_req: Request, res: Response) => {
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(users);
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/promote',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'MODERATOR' },
create: { pubkey, role: 'MODERATOR' },
});
res.json(user);
} catch (err) {
console.error('Promote user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/demote',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'USER' },
create: { pubkey, role: 'USER' },
});
res.json(user);
} catch (err) {
console.error('Demote user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/me/username-check',
requireAuth,
async (req: Request, res: Response) => {
try {
const username = (req.query.username as string || '').trim().toLowerCase();
const error = validateUsername(username);
if (error) {
res.json({ available: false, reason: error });
return;
}
const existing = await prisma.user.findFirst({
where: {
username: { equals: username },
NOT: { pubkey: req.user!.pubkey },
},
});
if (existing) {
res.json({ available: false, reason: 'Username is already taken' });
return;
}
res.json({ available: true });
} catch (err) {
console.error('Username check error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/me',
requireAuth,
async (req: Request, res: Response) => {
try {
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
const error = validateUsername(normalized);
if (error) {
res.status(400).json({ error });
return;
}
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey: req.user!.pubkey },
},
});
if (existing) {
res.status(409).json({ error: 'Username is already taken' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey: req.user!.pubkey },
update: { username: normalized },
create: { pubkey: req.user!.pubkey, username: normalized },
});
res.json(user);
} catch (err) {
console.error('Update profile error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

9
backend/src/db/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

71
backend/src/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import authRouter from './api/auth';
import postsRouter from './api/posts';
import meetupsRouter from './api/meetups';
import moderationRouter from './api/moderation';
import usersRouter from './api/users';
import categoriesRouter from './api/categories';
import relaysRouter from './api/relays';
import settingsRouter from './api/settings';
import nostrRouter from './api/nostr';
import submissionsRouter from './api/submissions';
import mediaRouter from './api/media';
import faqsRouter from './api/faqs';
import calendarRouter from './api/calendar';
import nip05Router from './api/nip05';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
// Trust the first proxy (nginx) so req.ip returns the real client IP
app.set('trust proxy', 1);
app.use(helmet());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use(cors({ origin: FRONTEND_URL, credentials: true }));
app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/meetups', meetupsRouter);
app.use('/api/moderation', moderationRouter);
app.use('/api/users', usersRouter);
app.use('/api/categories', categoriesRouter);
app.use('/api/relays', relaysRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/nostr', nostrRouter);
app.use('/api/submissions', submissionsRouter);
app.use('/api/media', mediaRouter);
app.use('/api/faqs', faqsRouter);
app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
const server = app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('Shutting down gracefully…');
server.close(() => {
console.log('Server closed.');
process.exit(0);
});
// Force exit if connections don't drain within 10 seconds
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

View File

@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
export interface AuthPayload {
pubkey: string;
role: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
export function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ error: 'Insufficient permissions' });
return;
}
next();
};
}

View File

@@ -0,0 +1,90 @@
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
interface StoredChallenge {
challenge: string;
expiresAt: number;
}
const challenges = new Map<string, StoredChallenge>();
// Periodically clean up expired challenges
setInterval(() => {
const now = Date.now();
for (const [key, value] of challenges) {
if (value.expiresAt < now) {
challenges.delete(key);
}
}
}, 60_000);
export const authService = {
createChallenge(pubkey: string): string {
const challenge = uuidv4();
challenges.set(pubkey, {
challenge,
expiresAt: Date.now() + CHALLENGE_TTL_MS,
});
return challenge;
},
verifySignature(pubkey: string, signedEvent: VerifiedEvent): boolean {
const stored = challenges.get(pubkey);
if (!stored) return false;
if (stored.expiresAt < Date.now()) {
challenges.delete(pubkey);
return false;
}
// Verify the event signature
if (!verifyEvent(signedEvent)) return false;
// Kind 22242 is the NIP-42 auth kind
if (signedEvent.kind !== 22242) return false;
if (signedEvent.pubkey !== pubkey) return false;
// Check that the challenge tag matches
const challengeTag = signedEvent.tags.find(
(t) => t[0] === 'challenge'
);
if (!challengeTag || challengeTag[1] !== stored.challenge) return false;
challenges.delete(pubkey);
return true;
},
generateToken(pubkey: string, role: string): string {
return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' });
},
async getRole(pubkey: string): Promise<string> {
const adminPubkeys = (process.env.ADMIN_PUBKEYS || '')
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
if (p.startsWith('npub1')) {
try {
const { data } = nip19.decode(p);
return data as string;
} catch {
return p;
}
}
return p;
});
if (adminPubkeys.includes(pubkey)) return 'ADMIN';
const user = await prisma.user.findUnique({ where: { pubkey } });
if (user?.role === 'MODERATOR') return 'MODERATOR';
if (user?.role === 'ADMIN') return 'ADMIN';
return 'USER';
},
};

View File

@@ -0,0 +1,146 @@
import { SimplePool, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
const pool = new SimplePool();
async function getRelayUrls(): Promise<string[]> {
const relays = await prisma.relay.findMany({
where: { active: true },
orderBy: { priority: 'asc' },
});
return relays.map((r) => r.url);
}
export const nostrService = {
async fetchEvent(eventId: string, skipCache = false) {
if (!skipCache) {
const cached = await prisma.nostrEventCache.findUnique({
where: { eventId },
});
if (cached) {
return {
id: cached.eventId,
kind: cached.kind,
pubkey: cached.pubkey,
content: cached.content,
tags: JSON.parse(cached.tags),
created_at: cached.createdAt,
};
}
}
const relays = await getRelayUrls();
if (relays.length === 0) return null;
try {
const event = await pool.get(relays, { ids: [eventId] });
if (!event) return null;
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
return event;
} catch (err) {
console.error('Failed to fetch event:', err);
return null;
}
},
async fetchLongformEvent(naddrStr: string) {
let decoded: nip19.AddressPointer;
try {
const result = nip19.decode(naddrStr);
if (result.type !== 'naddr') return null;
decoded = result.data;
} catch {
return null;
}
const relays = decoded.relays?.length
? decoded.relays
: await getRelayUrls();
if (relays.length === 0) return null;
try {
const event = await pool.get(relays, {
kinds: [decoded.kind],
authors: [decoded.pubkey],
'#d': [decoded.identifier],
});
if (!event) return null;
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
return event;
} catch (err) {
console.error('Failed to fetch longform event:', err);
return null;
}
},
async fetchReactions(eventId: string) {
const relays = await getRelayUrls();
if (relays.length === 0) return [];
try {
const events = await pool.querySync(relays, {
kinds: [7],
'#e': [eventId],
});
return events;
} catch (err) {
console.error('Failed to fetch reactions:', err);
return [];
}
},
async fetchReplies(eventId: string) {
const relays = await getRelayUrls();
if (relays.length === 0) return [];
try {
const events = await pool.querySync(relays, {
kinds: [1],
'#e': [eventId],
});
return events;
} catch (err) {
console.error('Failed to fetch replies:', err);
return [];
}
},
async getRelays() {
return prisma.relay.findMany({
where: { active: true },
orderBy: { priority: 'asc' },
});
},
};