Isolate next dev from production .next, add build-guard/atomic deploy/health watchdog, error boundaries, and fix JWT startup, meetup leaks, media path traversal, SVG/memory uploads, and JSON-LD escaping. Co-authored-by: Cursor <cursoragent@cursor.com>
175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
||
import { prisma } from '../db/prisma';
|
||
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
|
||
|
||
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({
|
||
// Public subscription feed — never expose HIDDEN or unpublished meetups.
|
||
where: {
|
||
date: { gte: cutoff },
|
||
visibility: 'PUBLIC',
|
||
status: 'PUBLISHED',
|
||
},
|
||
orderBy: { date: 'asc' },
|
||
include: { organizer: true },
|
||
});
|
||
|
||
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}`));
|
||
const orgName = meetup.organizer?.name || 'Belgian Bitcoin Embassy';
|
||
lines.push(
|
||
fold(
|
||
`ORGANIZER;CN=${escapeIcs(orgName)}: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);
|
||
if (respondIfOrganizerMigrationNeeded(err, res)) return;
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
export default router;
|