+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Selected Room
+
+
+
Meeting link inserted!
+
+
+
+
+
+
+
+
+
+
diff --git a/server/config/database.js b/server/config/database.js
index c3b2adc..0f2a220 100644
--- a/server/config/database.js
+++ b/server/config/database.js
@@ -438,6 +438,82 @@ export async function initDatabase() {
`);
}
+ // ── Calendar tables ──────────────────────────────────────────────────────
+ if (isPostgres) {
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS calendar_events (
+ id SERIAL PRIMARY KEY,
+ uid TEXT UNIQUE NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ start_time TIMESTAMP NOT NULL,
+ end_time TIMESTAMP NOT NULL,
+ room_uid TEXT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ color TEXT DEFAULT '#6366f1',
+ federated_from TEXT DEFAULT NULL,
+ federated_join_url TEXT DEFAULT NULL,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ );
+ CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
+ CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
+ CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
+
+ CREATE TABLE IF NOT EXISTS calendar_event_shares (
+ id SERIAL PRIMARY KEY,
+ event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(event_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
+ CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
+ `);
+ } else {
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS calendar_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ uid TEXT UNIQUE NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ start_time DATETIME NOT NULL,
+ end_time DATETIME NOT NULL,
+ room_uid TEXT,
+ user_id INTEGER NOT NULL,
+ color TEXT DEFAULT '#6366f1',
+ federated_from TEXT DEFAULT NULL,
+ federated_join_url TEXT DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
+ CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
+ CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
+
+ CREATE TABLE IF NOT EXISTS calendar_event_shares (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(event_id, user_id),
+ FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
+ CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
+ `);
+ }
+
+ // Calendar migrations: add federated columns if missing
+ if (!(await db.columnExists('calendar_events', 'federated_from'))) {
+ await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
+ }
+ if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
+ await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
+ }
+
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {
diff --git a/server/index.js b/server/index.js
index 76f7030..f539724 100644
--- a/server/index.js
+++ b/server/index.js
@@ -13,6 +13,7 @@ import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
+import calendarRoutes from './routes/calendar.js';
import { startFederationSync } from './jobs/federationSync.js';
const __filename = fileURLToPath(import.meta.url);
@@ -53,6 +54,9 @@ async function start() {
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
+ app.use('/api/calendar', calendarRoutes);
+ // Mount calendar federation receive also under /api/federation for remote instances
+ app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler);
// Serve static files in production
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
new file mode 100644
index 0000000..6a04858
--- /dev/null
+++ b/server/routes/calendar.js
@@ -0,0 +1,489 @@
+import { Router } from 'express';
+import crypto from 'crypto';
+import { getDb } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+import { log } from '../config/logger.js';
+import {
+ isFederationEnabled,
+ getFederationDomain,
+ signPayload,
+ verifyPayload,
+ discoverInstance,
+ parseAddress,
+} from '../config/federation.js';
+import { rateLimit } from 'express-rate-limit';
+
+const router = Router();
+
+// Rate limit for federation calendar receive
+const calendarFederationLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 100,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { error: 'Too many requests. Please try again later.' },
+});
+
+// ── GET /api/calendar/events — List events for the current user ─────────────
+router.get('/events', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const { from, to } = req.query;
+
+ let sql = `
+ SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
+ FROM calendar_events ce
+ JOIN users u ON ce.user_id = u.id
+ WHERE (ce.user_id = ? OR ce.id IN (
+ SELECT event_id FROM calendar_event_shares WHERE user_id = ?
+ ))
+ `;
+ const params = [req.user.id, req.user.id];
+
+ if (from) {
+ sql += ' AND ce.end_time >= ?';
+ params.push(from);
+ }
+ if (to) {
+ sql += ' AND ce.start_time <= ?';
+ params.push(to);
+ }
+
+ sql += ' ORDER BY ce.start_time ASC';
+ const events = await db.all(sql, params);
+
+ // Mark shared events
+ for (const ev of events) {
+ ev.is_owner = ev.user_id === req.user.id;
+ }
+
+ res.json({ events });
+ } catch (err) {
+ log.server.error(`Calendar list error: ${err.message}`);
+ res.status(500).json({ error: 'Events could not be loaded' });
+ }
+});
+
+// ── GET /api/calendar/events/:id — Get single event ─────────────────────────
+router.get('/events/:id', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const event = await db.get(`
+ SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
+ FROM calendar_events ce
+ JOIN users u ON ce.user_id = u.id
+ WHERE ce.id = ?
+ `, [req.params.id]);
+
+ if (!event) return res.status(404).json({ error: 'Event not found' });
+
+ // Check access
+ if (event.user_id !== req.user.id) {
+ const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
+ if (!share) return res.status(403).json({ error: 'No permission' });
+ }
+
+ // Get shared users
+ const sharedUsers = await db.all(`
+ SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
+ FROM calendar_event_shares ces
+ JOIN users u ON ces.user_id = u.id
+ WHERE ces.event_id = ?
+ `, [event.id]);
+
+ event.is_owner = event.user_id === req.user.id;
+ res.json({ event, sharedUsers });
+ } catch (err) {
+ log.server.error(`Calendar get event error: ${err.message}`);
+ res.status(500).json({ error: 'Event could not be loaded' });
+ }
+});
+
+// ── POST /api/calendar/events — Create event ────────────────────────────────
+router.post('/events', authenticateToken, async (req, res) => {
+ try {
+ const { title, description, start_time, end_time, room_uid, color } = req.body;
+
+ if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
+ if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
+ if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
+ if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
+
+ const startDate = new Date(start_time);
+ const endDate = new Date(end_time);
+ if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
+ return res.status(400).json({ error: 'Invalid date format' });
+ }
+ if (endDate <= startDate) {
+ return res.status(400).json({ error: 'End time must be after start time' });
+ }
+
+ // Verify room exists if specified
+ const db = getDb();
+ if (room_uid) {
+ const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
+ if (!room) return res.status(400).json({ error: 'Linked room not found' });
+ }
+
+ const uid = crypto.randomBytes(12).toString('hex');
+ const result = await db.run(`
+ INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `, [
+ uid,
+ title.trim(),
+ description || null,
+ startDate.toISOString(),
+ endDate.toISOString(),
+ room_uid || null,
+ req.user.id,
+ color || '#6366f1',
+ ]);
+
+ const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
+ res.status(201).json({ event });
+ } catch (err) {
+ log.server.error(`Calendar create error: ${err.message}`);
+ res.status(500).json({ error: 'Event could not be created' });
+ }
+});
+
+// ── PUT /api/calendar/events/:id — Update event ─────────────────────────────
+router.put('/events/:id', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
+ if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
+
+ const { title, description, start_time, end_time, room_uid, color } = req.body;
+
+ if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
+ if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
+
+ if (start_time && end_time) {
+ const s = new Date(start_time);
+ const e = new Date(end_time);
+ if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' });
+ if (e <= s) return res.status(400).json({ error: 'End time must be after start time' });
+ }
+
+ if (room_uid) {
+ const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
+ if (!room) return res.status(400).json({ error: 'Linked room not found' });
+ }
+
+ await db.run(`
+ UPDATE calendar_events SET
+ title = COALESCE(?, title),
+ description = ?,
+ start_time = COALESCE(?, start_time),
+ end_time = COALESCE(?, end_time),
+ room_uid = ?,
+ color = COALESCE(?, color),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `, [
+ title || null,
+ description !== undefined ? description : event.description,
+ start_time || null,
+ end_time || null,
+ room_uid !== undefined ? (room_uid || null) : event.room_uid,
+ color || null,
+ req.params.id,
+ ]);
+
+ const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]);
+ res.json({ event: updated });
+ } catch (err) {
+ log.server.error(`Calendar update error: ${err.message}`);
+ res.status(500).json({ error: 'Event could not be updated' });
+ }
+});
+
+// ── DELETE /api/calendar/events/:id — Delete event ──────────────────────────
+router.delete('/events/:id', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
+ if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
+
+ await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]);
+ res.json({ message: 'Event deleted' });
+ } catch (err) {
+ log.server.error(`Calendar delete error: ${err.message}`);
+ res.status(500).json({ error: 'Event could not be deleted' });
+ }
+});
+
+// ── POST /api/calendar/events/:id/share — Share event with local user ───────
+router.post('/events/:id/share', authenticateToken, async (req, res) => {
+ try {
+ const { user_id } = req.body;
+ if (!user_id) return res.status(400).json({ error: 'User ID is required' });
+
+ const db = getDb();
+ const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
+ if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
+ if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' });
+
+ const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]);
+ if (existing) return res.status(400).json({ error: 'Already shared with this user' });
+
+ await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [event.id, user_id]);
+
+ const sharedUsers = await db.all(`
+ SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
+ FROM calendar_event_shares ces
+ JOIN users u ON ces.user_id = u.id
+ WHERE ces.event_id = ?
+ `, [event.id]);
+
+ res.json({ sharedUsers });
+ } catch (err) {
+ log.server.error(`Calendar share error: ${err.message}`);
+ res.status(500).json({ error: 'Could not share event' });
+ }
+});
+
+// ── DELETE /api/calendar/events/:id/share/:userId — Remove share ────────────
+router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
+ if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
+
+ await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]);
+
+ const sharedUsers = await db.all(`
+ SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
+ FROM calendar_event_shares ces
+ JOIN users u ON ces.user_id = u.id
+ WHERE ces.event_id = ?
+ `, [event.id]);
+
+ res.json({ sharedUsers });
+ } catch (err) {
+ log.server.error(`Calendar unshare error: ${err.message}`);
+ res.status(500).json({ error: 'Could not remove share' });
+ }
+});
+
+// ── GET /api/calendar/events/:id/ics — Download event as ICS ────────────────
+router.get('/events/:id/ics', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const event = await db.get(`
+ SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email
+ FROM calendar_events ce
+ JOIN users u ON ce.user_id = u.id
+ WHERE ce.id = ?
+ `, [req.params.id]);
+
+ if (!event) return res.status(404).json({ error: 'Event not found' });
+
+ // Check access
+ if (event.user_id !== req.user.id) {
+ const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
+ if (!share) return res.status(403).json({ error: 'No permission' });
+ }
+
+ // Build room join URL if linked
+ const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
+ let location = '';
+ if (event.room_uid) {
+ location = `${baseUrl}/join/${event.room_uid}`;
+ }
+
+ const ics = generateICS(event, location, baseUrl);
+
+ res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`);
+ res.send(ics);
+ } catch (err) {
+ log.server.error(`ICS download error: ${err.message}`);
+ res.status(500).json({ error: 'Could not generate ICS file' });
+ }
+});
+
+// ── POST /api/calendar/events/:id/federation — Send event to remote user ────
+router.post('/events/:id/federation', authenticateToken, async (req, res) => {
+ try {
+ if (!isFederationEnabled()) {
+ return res.status(400).json({ error: 'Federation is not configured on this instance' });
+ }
+
+ const { to } = req.body;
+ if (!to) return res.status(400).json({ error: 'Remote address is required' });
+
+ const { username, domain } = parseAddress(to);
+ if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' });
+ if (domain === getFederationDomain()) {
+ return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' });
+ }
+
+ const db = getDb();
+ const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
+ if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
+
+ const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
+ let joinUrl = null;
+ if (event.room_uid) {
+ joinUrl = `${baseUrl}/join/${event.room_uid}`;
+ }
+
+ const payload = {
+ type: 'calendar_event',
+ event_uid: event.uid,
+ title: event.title,
+ description: event.description || '',
+ start_time: event.start_time,
+ end_time: event.end_time,
+ room_uid: event.room_uid || null,
+ join_url: joinUrl,
+ from_user: `@${req.user.name}@${getFederationDomain()}`,
+ to_user: to,
+ timestamp: new Date().toISOString(),
+ };
+
+ const signature = signPayload(payload);
+ const { baseUrl: remoteApi } = await discoverInstance(domain);
+
+ const response = await fetch(`${remoteApi}/calendar-event`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Federation-Signature': signature,
+ 'X-Federation-Origin': getFederationDomain(),
+ },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.error || `Remote server responded with ${response.status}`);
+ }
+
+ res.json({ success: true });
+ } catch (err) {
+ log.server.error(`Calendar federation send error: ${err.message}`);
+ res.status(500).json({ error: err.message || 'Could not send event to remote instance' });
+ }
+});
+
+// ── POST /receive-event or /calendar-event — Receive calendar event from remote ──
+// '/receive-event' when mounted at /api/calendar
+// '/calendar-event' when mounted at /api/federation (for remote instance discovery)
+router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => {
+ try {
+ if (!isFederationEnabled()) {
+ return res.status(400).json({ error: 'Federation is not configured on this instance' });
+ }
+
+ const signature = req.headers['x-federation-signature'];
+ const payload = req.body || {};
+
+ if (!signature) return res.status(401).json({ error: 'Missing federation signature' });
+
+ const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload;
+
+ if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) {
+ return res.status(400).json({ error: 'Incomplete event payload' });
+ }
+
+ // Validate lengths
+ if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) ||
+ from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) {
+ return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
+ }
+
+ // Verify signature
+ const { domain: senderDomain } = parseAddress(from_user);
+ if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' });
+
+ const { publicKey } = await discoverInstance(senderDomain);
+ if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' });
+ if (!verifyPayload(payload, signature, publicKey)) {
+ return res.status(403).json({ error: 'Invalid federation signature' });
+ }
+
+ // Find local user
+ const { username } = parseAddress(to_user);
+ const db = getDb();
+ const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]);
+ if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
+
+ // Check duplicate
+ const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]);
+ if (existing) return res.json({ success: true, message: 'Event already received' });
+
+ // Create event for the target user
+ await db.run(`
+ INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, [
+ event_uid,
+ title,
+ description || null,
+ start_time,
+ end_time,
+ room_uid || null,
+ targetUser.id,
+ '#6366f1',
+ from_user,
+ join_url || null,
+ ]);
+
+ res.json({ success: true });
+ } catch (err) {
+ log.server.error(`Calendar federation receive error: ${err.message}`);
+ res.status(500).json({ error: 'Failed to process calendar event' });
+ }
+});
+
+// ── Helper: Generate ICS content ────────────────────────────────────────────
+function generateICS(event, location, prodIdDomain) {
+ const formatDate = (dateStr) => {
+ const d = new Date(dateStr);
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ };
+
+ const escapeICS = (str) => {
+ if (!str) return '';
+ return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
+ };
+
+ const now = formatDate(new Date().toISOString());
+ const dtStart = formatDate(event.start_time);
+ const dtEnd = formatDate(event.end_time);
+
+ let ics = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ `PRODID:-//${prodIdDomain}//Redlight Calendar//EN`,
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ 'BEGIN:VEVENT',
+ `UID:${event.uid}@${prodIdDomain}`,
+ `DTSTAMP:${now}`,
+ `DTSTART:${dtStart}`,
+ `DTEND:${dtEnd}`,
+ `SUMMARY:${escapeICS(event.title)}`,
+ ];
+
+ if (event.description) {
+ ics.push(`DESCRIPTION:${escapeICS(event.description)}`);
+ }
+ if (location) {
+ ics.push(`LOCATION:${escapeICS(location)}`);
+ ics.push(`URL:${location}`);
+ }
+ if (event.organizer_name && event.organizer_email) {
+ ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
+ }
+
+ ics.push('END:VEVENT', 'END:VCALENDAR');
+ return ics.join('\r\n');
+}
+
+export default router;
diff --git a/src/App.jsx b/src/App.jsx
index 74a6536..1552f3d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -16,6 +16,7 @@ import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail';
+import Calendar from './pages/Calendar';
export default function App() {
const { user, loading } = useAuth();
@@ -54,6 +55,7 @@ export default function App() {
{/* Protected routes */}