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

View File

@@ -0,0 +1,99 @@
admin
administrator
root
superuser
support
help
helpdesk
contact
info
noreply
no-reply
postmaster
webmaster
hostmaster
abuse
security
privacy
legal
press
media
marketing
nostr
bitcoin
btc
lightning
lnbc
embassy
belgianbitcoinembassy
bbe
api
system
daemon
service
server
www
mail
email
ftp
smtp
imap
pop
pop3
mx
ns
dns
cdn
static
assets
img
images
video
videos
files
uploads
download
downloads
backup
dev
staging
test
testing
demo
example
sample
null
undefined
true
false
me
you
we
they
user
users
account
accounts
profile
profiles
login
logout
signin
signup
register
password
reset
verify
auth
oauth
callback
redirect
feed
rss
atom
sitemap
robots
favicon
wellknown
_
__

2441
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
backend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "bbe-backend",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"multer": "^2.1.1",
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/helmet": "^0.0.48",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,174 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"displayName" TEXT,
"username" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Meetup" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"location" TEXT NOT NULL,
"link" TEXT,
"imageId" TEXT,
"status" TEXT NOT NULL DEFAULT 'UPCOMING',
"featured" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Media" (
"id" TEXT NOT NULL PRIMARY KEY,
"slug" TEXT NOT NULL,
"type" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"originalFilename" TEXT NOT NULL,
"uploadedBy" TEXT NOT NULL,
"title" TEXT,
"description" TEXT,
"altText" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL PRIMARY KEY,
"nostrEventId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"content" TEXT NOT NULL,
"excerpt" TEXT,
"authorPubkey" TEXT NOT NULL,
"authorName" TEXT,
"featured" BOOLEAN NOT NULL DEFAULT false,
"visible" BOOLEAN NOT NULL DEFAULT true,
"publishedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "PostCategory" (
"postId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
PRIMARY KEY ("postId", "categoryId"),
CONSTRAINT "PostCategory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PostCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "HiddenContent" (
"id" TEXT NOT NULL PRIMARY KEY,
"nostrEventId" TEXT NOT NULL,
"reason" TEXT,
"hiddenBy" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "BlockedPubkey" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"reason" TEXT,
"blockedBy" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Relay" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Setting" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "NostrEventCache" (
"id" TEXT NOT NULL PRIMARY KEY,
"eventId" TEXT NOT NULL,
"kind" INTEGER NOT NULL,
"pubkey" TEXT NOT NULL,
"content" TEXT NOT NULL,
"tags" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL,
"cachedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Submission" (
"id" TEXT NOT NULL PRIMARY KEY,
"eventId" TEXT,
"naddr" TEXT,
"title" TEXT NOT NULL,
"authorPubkey" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"reviewedBy" TEXT,
"reviewNote" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Faq" (
"id" TEXT NOT NULL PRIMARY KEY,
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"showOnHomepage" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Post_nostrEventId_key" ON "Post"("nostrEventId");
-- CreateIndex
CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Relay_url_key" ON "Relay"("url");
-- CreateIndex
CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key");
-- CreateIndex
CREATE UNIQUE INDEX "NostrEventCache_eventId_key" ON "NostrEventCache"("eventId");

View File

@@ -0,0 +1,23 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Meetup" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"location" TEXT NOT NULL,
"link" TEXT,
"imageId" TEXT,
"status" TEXT NOT NULL DEFAULT 'UPCOMING',
"featured" BOOLEAN NOT NULL DEFAULT false,
"visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt") SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,23 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Meetup" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"location" TEXT NOT NULL,
"link" TEXT,
"imageId" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"featured" BOOLEAN NOT NULL DEFAULT false,
"visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt", "visibility") SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt", "visibility" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -0,0 +1,149 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
pubkey String @unique
role String @default("USER") // USER, MODERATOR, ADMIN
displayName String?
username String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Meetup {
id String @id @default(uuid())
title String
description String
date String
time String
location String
link String?
imageId String?
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Media {
id String @id // ULID, not auto-generated
slug String
type String // "image" | "video"
mimeType String
size Int
originalFilename String
uploadedBy String
title String?
description String?
altText String?
createdAt DateTime @default(now())
}
model Post {
id String @id @default(uuid())
nostrEventId String @unique
title String
slug String @unique
content String
excerpt String?
authorPubkey String
authorName String?
featured Boolean @default(false)
visible Boolean @default(true)
publishedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories PostCategory[]
}
model Category {
id String @id @default(uuid())
name String
slug String @unique
sortOrder Int @default(0)
createdAt DateTime @default(now())
posts PostCategory[]
}
model PostCategory {
postId String
categoryId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
}
model HiddenContent {
id String @id @default(uuid())
nostrEventId String
reason String?
hiddenBy String
createdAt DateTime @default(now())
}
model BlockedPubkey {
id String @id @default(uuid())
pubkey String
reason String?
blockedBy String
createdAt DateTime @default(now())
}
model Relay {
id String @id @default(uuid())
url String @unique
priority Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
}
model Setting {
id String @id @default(uuid())
key String @unique
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NostrEventCache {
id String @id @default(uuid())
eventId String @unique
kind Int
pubkey String
content String
tags String // JSON string
createdAt Int // event timestamp
cachedAt DateTime @default(now())
}
model Submission {
id String @id @default(uuid())
eventId String?
naddr String?
title String
authorPubkey String
status String @default("PENDING") // PENDING, APPROVED, REJECTED
reviewedBy String?
reviewNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Faq {
id String @id @default(uuid())
question String
answer String
order Int @default(0)
showOnHomepage Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

85
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,85 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const relays = [
{ url: 'wss://relay.damus.io', priority: 1 },
{ url: 'wss://nos.lol', priority: 2 },
{ url: 'wss://relay.nostr.band', priority: 3 },
];
for (const relay of relays) {
await prisma.relay.upsert({
where: { url: relay.url },
update: {},
create: relay,
});
}
const settings = [
{ key: 'site_title', value: 'Belgian Bitcoin Embassy' },
{ key: 'site_tagline', value: 'Your gateway to Bitcoin in Belgium' },
{ key: 'telegram_link', value: 'https://t.me/belgianbitcoinembassy' },
{ key: 'nostr_link', value: '' },
{ key: 'x_link', value: '' },
{ key: 'youtube_link', value: '' },
{ key: 'discord_link', value: '' },
{ key: 'linkedin_link', value: '' },
];
for (const setting of settings) {
await prisma.setting.upsert({
where: { key: setting.key },
update: {},
create: setting,
});
}
const categories = [
{ name: 'Bitcoin', slug: 'bitcoin', sortOrder: 1 },
{ name: 'Lightning', slug: 'lightning', sortOrder: 2 },
{ name: 'Privacy', slug: 'privacy', sortOrder: 3 },
{ name: 'Education', slug: 'education', sortOrder: 4 },
{ name: 'Community', slug: 'community', sortOrder: 5 },
];
for (const category of categories) {
await prisma.category.upsert({
where: { slug: category.slug },
update: {},
create: category,
});
}
const existingMeetup = await prisma.meetup.findFirst({
where: { title: 'Monthly Bitcoin Meetup' },
});
if (!existingMeetup) {
await prisma.meetup.create({
data: {
title: 'Monthly Bitcoin Meetup',
description:
'Join us for our monthly Bitcoin meetup! We discuss the latest developments, share knowledge, and connect with fellow Bitcoiners in Belgium.',
date: '2025-02-15',
time: '19:00',
location: 'Brussels, Belgium',
link: 'https://meetup.com/example',
status: 'UPCOMING',
featured: true,
},
});
}
console.log('Seed completed successfully.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

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' },
});
},
};

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}