Files
BelgianBitcoinEmbassy/backend/src/api/calendar.ts
T
bbeandCursor ede8817583 fix: harden deploys and close top security holes after /events outage
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>
2026-07-18 19:14:57 +02:00

175 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;