first commit
Made-with: Cursor
This commit is contained in:
99
backend/config/blocked-usernames.txt
Normal file
99
backend/config/blocked-usernames.txt
Normal 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
2441
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
backend/package.json
Normal file
41
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
149
backend/prisma/schema.prisma
Normal file
149
backend/prisma/schema.prisma
Normal 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
85
backend/prisma/seed.ts
Normal 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
53
backend/src/api/auth.ts
Normal 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
163
backend/src/api/calendar.ts
Normal 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;
|
||||
103
backend/src/api/categories.ts
Normal file
103
backend/src/api/categories.ts
Normal 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
155
backend/src/api/faqs.ts
Normal 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
217
backend/src/api/media.ts
Normal 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
253
backend/src/api/meetups.ts
Normal 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;
|
||||
143
backend/src/api/moderation.ts
Normal file
143
backend/src/api/moderation.ts
Normal 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
32
backend/src/api/nip05.ts
Normal 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
88
backend/src/api/nostr.ts
Normal 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
243
backend/src/api/posts.ts
Normal 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
141
backend/src/api/relays.ts
Normal 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;
|
||||
79
backend/src/api/settings.ts
Normal file
79
backend/src/api/settings.ts
Normal 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;
|
||||
114
backend/src/api/submissions.ts
Normal file
114
backend/src/api/submissions.ts
Normal 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
177
backend/src/api/users.ts
Normal 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
9
backend/src/db/prisma.ts
Normal 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
71
backend/src/index.ts
Normal 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);
|
||||
48
backend/src/middleware/auth.ts
Normal file
48
backend/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
90
backend/src/services/auth.ts
Normal file
90
backend/src/services/auth.ts
Normal 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';
|
||||
},
|
||||
};
|
||||
146
backend/src/services/nostr.ts
Normal file
146
backend/src/services/nostr.ts
Normal 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
19
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user