first commit
Made-with: Cursor
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user