// Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars). // Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window. const fs = require('fs'); const atomicWrite = require('./atomic'); const path = require('path'); const dns = require('dns').promises; const net = require('net'); const crypto = require('crypto'); const ics = require('./ics'); const caldav = require('./caldav'); const users = require('./users'); const here = f => path.join(users.dir(), f); // each person's own files const FILE_NAME = 'calendars.json'; const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } }; const save = v => atomicWrite(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 }); // ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses. const isPrivate = ip => { if (net.isIPv6(ip)) { const l = ip.toLowerCase(); return l === '::1' || l.startsWith('fc') || l.startsWith('fd') || l.startsWith('fe80') || l.startsWith('::ffff:') && isPrivate(l.slice(7)); } const [a, b] = ip.split('.').map(Number); return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127) || a >= 224; }; async function assertPublic(u) { if (u.protocol !== 'https:') throw new Error('Calendar links must be https (webcal:// is fine)'); const host = u.hostname.replace(/^\[|\]$/g, ''); const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true }); if (!addrs.length || addrs.some(a => isPrivate(a.address))) throw new Error('That address is not allowed'); } async function fetchIcs(raw) { let u = new URL(raw.replace(/^webcal:/i, 'https:')); for (let hop = 0; hop < 4; hop++) { await assertPublic(u); const r = await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(12000), headers: { Accept: 'text/calendar, */*' } }); if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; } if (!r.ok) throw new Error('Calendar server answered ' + r.status); const text = await r.text(); if (text.length > 8e6) throw new Error('Calendar file is too large'); if (!/BEGIN:VCALENDAR/i.test(text)) throw new Error('That link is not a calendar (ICS) file'); return text; } throw new Error('Too many redirects'); } const cache = new Map(); // url -> { t, text } async function getIcs(url) { const c = cache.get(url); if (c && Date.now() - c.t < 10 * 60e3) return c.text; const text = await fetchIcs(url); cache.set(url, { t: Date.now(), text }); return text; } // ---- Parsing const WIN_TZ = { 'Eastern Standard Time': 'America/New_York', 'Central Standard Time': 'America/Chicago', 'Mountain Standard Time': 'America/Denver', 'Pacific Standard Time': 'America/Los_Angeles', 'US Mountain Standard Time': 'America/Phoenix', 'Alaskan Standard Time': 'America/Anchorage', 'Hawaiian Standard Time': 'Pacific/Honolulu', 'GMT Standard Time': 'Europe/London', 'W. Europe Standard Time': 'Europe/Berlin', 'Central Europe Standard Time': 'Europe/Budapest', 'Romance Standard Time': 'Europe/Paris', 'UTC': 'UTC' }; const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } }; const tzName = tz => (tz && (validTz(tz) ? tz : WIN_TZ[tz])) || null; const fmts = new Map(); // Wall-clock time in a zone -> UTC ms. function zonedToUtc(y, mo, d, h, mi, s, tz) { const guess = Date.UTC(y, mo - 1, d, h, mi, s); if (!tz) return guess; let fmt = fmts.get(tz); if (!fmt) fmts.set(tz, fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })); const offset = t => { const p = Object.fromEntries(fmt.formatToParts(new Date(t)).map(x => [x.type, +x.value])); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - t; }; let t = guess - offset(guess); t = guess - offset(t); // second pass settles DST edges return t; } function unfold(text) { return text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/); } function parseLine(line) { const i = line.indexOf(':'); if (i < 0) return null; const [name, ...params] = line.slice(0, i).split(';'); const p = {}; params.forEach(x => { const [k, v] = x.split('='); p[k.toUpperCase()] = (v || '').replace(/^"|"$/g, ''); }); return { name: name.toUpperCase(), params: p, value: line.slice(i + 1) }; } const unesc = v => v.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1'); // "20261020T090000Z" | "20261020T090000" | "20261020" -> { allDay, y, mo, d, h, mi, s, utc, tz } function parseDate(value, params) { const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim()); if (!m) return null; const allDay = !m[4] || params.VALUE === 'DATE'; const r = { allDay, y: +m[1], mo: +m[2], d: +m[3], h: +(m[4] || 0), mi: +(m[5] || 0), s: +(m[6] || 0), tz: m[7] ? 'UTC' : tzName(params.TZID) }; r.ms = allDay ? Date.UTC(r.y, r.mo - 1, r.d) : zonedToUtc(r.y, r.mo, r.d, r.h, r.mi, r.s, r.tz); return r; } function parseDuration(v) { const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(v.trim()); if (!m) return null; const sign = m[1] === '-' ? -1 : 1; return sign * (((+m[2] || 0) * 7 + (+m[3] || 0)) * 864e5 + (+m[4] || 0) * 36e5 + (+m[5] || 0) * 6e4 + (+m[6] || 0) * 1e3); } function parseEvents(text) { const events = []; let cur = null, depth = 0; for (const line of unfold(text)) { if (line === 'BEGIN:VEVENT') { cur = { ex: [] }; depth = 0; continue; } if (line === 'END:VEVENT') { if (cur && cur.start) events.push(cur); cur = null; continue; } if (!cur) continue; if (/^BEGIN:/.test(line)) { depth++; continue; } // skip nested VALARM so its DESCRIPTION/SUMMARY never replaces the event's if (/^END:/.test(line)) { depth--; continue; } if (depth > 0) continue; const l = parseLine(line); if (!l) continue; switch (l.name) { case 'DTSTART': cur.start = parseDate(l.value, l.params); break; case 'DTEND': cur.end = parseDate(l.value, l.params); break; case 'DURATION': cur.dur = parseDuration(l.value); break; case 'SUMMARY': cur.title = unesc(l.value); break; case 'LOCATION': cur.location = unesc(l.value); break; case 'DESCRIPTION': cur.notes = unesc(l.value); break; case 'UID': cur.uid = l.value; break; case 'STATUS': cur.status = l.value.toUpperCase(); break; case 'RRULE': cur.rrule = Object.fromEntries(l.value.split(';').map(x => x.split('='))); break; case 'EXDATE': l.value.split(',').forEach(v => { const d = parseDate(v, l.params); if (d) cur.ex.push(d.allDay ? d.ms : d.ms); }); break; case 'RECURRENCE-ID': cur.recId = parseDate(l.value, l.params); break; } } return events; } // ---- Expansion const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate(); function nthWeekday(y, m, dow, n) { // n>0: nth, n<0: nth from end if (n > 0) { const first = new Date(Date.UTC(y, m - 1, 1)).getUTCDay(); const d = 1 + ((dow - first + 7) % 7) + (n - 1) * 7; return d <= daysInMonth(y, m) ? d : null; } const dim = daysInMonth(y, m), last = new Date(Date.UTC(y, m - 1, dim)).getUTCDay(); const d = dim - ((last - dow + 7) % 7) + (n + 1) * 7; return d >= 1 ? d : null; } // Occurrence start dates as wall-clock {y,mo,d}, from DTSTART forward, within [..until]. function* occurrences(ev, windowStartMs, windowEndMs) { const s = ev.start, r = ev.rrule; const tod = { h: s.h, mi: s.mi, s: s.s }; const toMs = ({ y, mo, d }) => s.allDay ? Date.UTC(y, mo - 1, d) : zonedToUtc(y, mo, d, tod.h, tod.mi, tod.s, s.tz); if (!r) { yield { y: s.y, mo: s.mo, d: s.d }; return; } const interval = Math.max(1, +r.INTERVAL || 1), count = r.COUNT ? +r.COUNT : Infinity; const until = r.UNTIL ? parseDate(r.UNTIL, {}) : null; const byday = r.BYDAY ? r.BYDAY.split(',').map(x => { const m = /^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(x); return m && { n: m[1] ? +m[1] : 0, dow: DAYS.indexOf(m[2]) }; }).filter(Boolean) : null; const bymd = r.BYMONTHDAY ? r.BYMONTHDAY.split(',').map(Number) : null; const bymonth = r.BYMONTH ? r.BYMONTH.split(',').map(Number) : null; let n = 0; const ok = c => { const ms = toMs(c); return ms >= s.ms && (!until || ms <= until.ms + (until.allDay ? 864e5 - 1 : 0)); }; const emit = function* (c) { if (!ok(c)) return; if (n >= count) return 'stop'; n++; yield c; }; const startDate = Date.UTC(s.y, s.mo - 1, s.d); // Without COUNT we can jump close to the window instead of walking from the original start. let i0 = 0; if (!r.COUNT && windowStartMs > s.ms) { const dayDiff = (windowStartMs - startDate) / 864e5, monthDiff = (new Date(windowStartMs).getUTCFullYear() - s.y) * 12 + new Date(windowStartMs).getUTCMonth() - (s.mo - 1); const steps = { DAILY: dayDiff / interval, WEEKLY: dayDiff / 7 / interval, MONTHLY: monthDiff / interval, YEARLY: monthDiff / 12 / interval }[r.FREQ] || 0; i0 = Math.max(0, Math.floor(steps) - 2); } for (let i = i0; i < i0 + 5000; i++) { let cands = []; if (r.FREQ === 'DAILY') { const dt = new Date(startDate + i * interval * 864e5); cands = [{ y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }]; } else if (r.FREQ === 'WEEKLY') { const weekStart = startDate - new Date(startDate).getUTCDay() * 864e5 + i * interval * 7 * 864e5; const dows = byday ? byday.map(b => b.dow) : [new Date(startDate).getUTCDay()]; cands = dows.sort((a, b) => a - b).map(dow => { const dt = new Date(weekStart + dow * 864e5); return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }; }); } else if (r.FREQ === 'MONTHLY') { const mIdx = s.mo - 1 + i * interval, y = s.y + Math.floor(mIdx / 12), mo = (mIdx % 12) + 1; if (byday) cands = byday.map(b => b.n ? nthWeekday(y, mo, b.dow, b.n) : null).filter(Boolean).map(d => ({ y, mo, d })); else (bymd || [s.d]).forEach(d => { const dd = d < 0 ? daysInMonth(y, mo) + d + 1 : d; if (dd >= 1 && dd <= daysInMonth(y, mo)) cands.push({ y, mo, d: dd }); }); cands.sort((a, b) => a.d - b.d); } else if (r.FREQ === 'YEARLY') { const y = s.y + i * interval; const months = bymonth || [s.mo]; for (const mo of months) { if (byday) byday.forEach(b => { const d = b.n ? nthWeekday(y, mo, b.dow, b.n) : null; if (d) cands.push({ y, mo, d }); }); else (bymd || [s.d]).forEach(d => { if (d <= daysInMonth(y, mo)) cands.push({ y, mo, d }); }); } } else { yield { y: s.y, mo: s.mo, d: s.d }; return; } if (bymonth && r.FREQ !== 'YEARLY') cands = cands.filter(c => bymonth.includes(c.mo)); if (!cands.length && i > 4000) return; for (const c of cands) { if (toMs(c) < s.ms) continue; if (toMs(c) > windowEndMs) return; const res = yield* emit(c); if (res === 'stop') return; } if (cands.length && toMs(cands[0]) > windowEndMs) return; if (until && cands.length && toMs(cands[0]) > until.ms + 864e5) return; } } const pad = n => String(n).padStart(2, '0'); const dateStr = ms => { const d = new Date(ms); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; }; function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD, inclusive) } const out = []; const overrides = new Set(events.filter(e => e.recId).map(e => e.uid + '|' + e.recId.ms)); for (const ev of events) { if (ev.status === 'CANCELLED') continue; const s = ev.start; const durMs = ev.end ? ev.end.ms - s.ms : ev.dur != null ? ev.dur : s.allDay ? 864e5 : 0; const add = (startMs) => { const endMs = startMs + durMs; if (s.allDay) { const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5)); if (ed <= win.fd || sd > win.td) return; out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed, uid: ev.uid, series: !!ev.rrule }); } else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs, uid: ev.uid, series: !!ev.rrule }); }; if (!ev.rrule || ev.recId) { add(s.ms); continue; } let guard = 0; for (const c of occurrences(ev, win.from - 864e5, win.to + 864e5)) { const ms = s.allDay ? Date.UTC(c.y, c.mo - 1, c.d) : zonedToUtc(c.y, c.mo, c.d, s.h, s.mi, s.s, s.tz); if (ev.ex.includes(ms) || overrides.has(ev.uid + '|' + ms)) continue; add(ms); if (++guard > 3000) break; } } return out; } // ---- NE Hub calendars: any number per person, stored here, shared by choice, and published as subscribe feeds. const EV_FILE = 'events.json', FEED_FILE = 'feed.json', NCAL_FILE = 'ncals.json', COLOR_FILE = 'calcolors.json'; const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 }); const HEX = /^#[0-9a-f]{6}$/i; const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d']; const dirFor = id => users.dirOf(id); const myId = () => users.current().id; /** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */ function calsIn(dir) { const l = readJ(path.join(dir, NCAL_FILE), null); return Array.isArray(l) && l.length ? l : [{ id: 'local', name: 'NE Hub', color: DEFAULT_COLOR, shares: [] }]; } const saveCals = v => writeJ(NCAL_FILE, v); const eventsIn = dir => readJ(path.join(dir, EV_FILE), []); const saveEvents = (dir, v) => atomicWrite(path.join(dir, EV_FILE), JSON.stringify(v, null, 2), { mode: 0o600 }); const evCal = e => e.cal || 'local'; const colorOverrides = () => readJ(here(COLOR_FILE), {}); /** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */ function sharedWithMe() { const me = myId(), out = []; for (const u of users.all()) { if (u.disabled || u.id === me) continue; for (const c of calsIn(dirFor(u.id))) { const sh = (c.shares || []).find(x => x.user === me); if (sh) out.push({ id: u.id + '~' + c.id, owner: u.id, ownerName: u.name, cal: c, mode: sh.mode === 'edit' ? 'edit' : 'view' }); } } return out; } /** Find the NE Hub calendar an id points at, with the current person's rights on it (null if none). */ function resolveCal(id) { const me = myId(); if (typeof id === 'string' && id.includes('~')) { const [owner, cid] = id.split('~'); const u = users.get(owner); if (!u || u.disabled) return null; const c = calsIn(dirFor(owner)).find(x => x.id === cid); const sh = c && (c.shares || []).find(x => x.user === me); return sh ? { owner, dir: dirFor(owner), cal: c, cid, mode: sh.mode === 'edit' ? 'edit' : 'view' } : null; } const c = calsIn(dirFor(me)).find(x => x.id === id); return c ? { owner: me, dir: dirFor(me), cal: c, cid: c.id, mode: 'owner' } : null; } const canWrite = r => r && (r.mode === 'owner' || r.mode === 'edit'); const icsOf = (dir, ids) => ics.buildCalendar(eventsIn(dir).filter(e => !ids || ids.includes(evCal(e))).map(e => ({ uid: e.uid, fields: e.fields }))); const localIcs = () => icsOf(dirFor(myId())); function feedToken(reset) { let t = readJ(here(FEED_FILE), {}).token; if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); } return t; } function calToken(id, reset) { const cals = calsIn(users.dir()), c = cals.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); if (!c.token || reset) { c.token = crypto.randomBytes(24).toString('hex'); saveCals(cals); } return c.token; } /** GET /cal/feed/.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret. * A person's own token shows all their NE Hub calendars; a calendar's token shows just that one. */ function feed(req, res) { const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname); const got = Buffer.from(m ? m[1] : ''); const same = t => { const want = Buffer.from(String(t || '')); return want.length && want.length === got.length && crypto.timingSafeEqual(want, got); }; let body = null; if (m) for (const u of users.all()) { if (u.disabled) continue; const dir = dirFor(u.id); if (same(readJ(path.join(dir, FEED_FILE), {}).token)) { body = icsOf(dir); break; } const c = calsIn(dir).find(x => same(x.token)); if (c) { body = icsOf(dir, [c.id]); break; } } if (body == null) { res.writeHead(404); return res.end('Not found'); } res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' }); res.end(body); } // ---- HTTP API (called by server.js, behind the passkey gate) const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; /** Every event in the window from all calendars (ICS links, iCloud, NE Hub, shared): { events, errors } */ async function collectEvents(win) { const all = [], errors = [], over = colorOverrides(); await Promise.all(load().map(async c => { try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, calId: c.id, color: c.color }); } catch (e) { errors.push(`${c.name}: ${e.message}`); } })); try { for (const r of await caldav.listRaw(win.from, win.to)) for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: over[r.calId] || r.color, editable: true, href: r.href, etag: r.etag }); } catch (e) { errors.push('iCloud: ' + e.message); } try { const mine = dirFor(myId()), evs = eventsIn(mine); const add = (dir, evs, c, id, extra) => { const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields }))); for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, ...extra }); }; for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true }); for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName }); } catch (e) { errors.push('NE Hub: ' + e.message); } return { events: all, errors }; } async function handle(req, res) { const u = new URL(req.url, 'http://x'); try { if (u.pathname === '/api/cal/list') { const over = colorOverrides(); return json(res, 200, [ ...calsIn(users.dir()).map(c => ({ id: c.id, name: c.name, color: c.color || DEFAULT_COLOR, local: true, shares: (c.shares || []).filter(x => users.get(x.user)) })), ...sharedWithMe().map(s => ({ id: s.id, name: s.cal.name, color: over[s.id] || s.cal.color || DEFAULT_COLOR, local: true, shared: true, owner: s.ownerName, mode: s.mode })), ...caldav.enabled().map(c => ({ ...c, color: over[c.id] || c.color, icloud: true })), ...load().map(({ id, name, color }) => ({ id, name, color }))]); } if (u.pathname === '/api/cal/ncal' && req.method === 'POST') { // create const { name, color } = JSON.parse(await readBody(req)); const cals = calsIn(users.dir()), nm = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 40); if (!nm) throw new Error('Give the calendar a name'); if (cals.length >= 30) throw new Error('That is plenty of calendars'); const c = { id: crypto.randomBytes(5).toString('hex'), name: nm, color: HEX.test(color) ? color : COLORS[cals.length % COLORS.length], shares: [] }; saveCals([...cals, c]); return json(res, 200, { ok: true, id: c.id }); } if (u.pathname === '/api/cal/ncal/update' && req.method === 'POST') { // rename, recolor, share const b = JSON.parse(await readBody(req)); const cals = calsIn(users.dir()), c = cals.find(x => x.id === b.id); if (!c) throw new Error('Calendar not found'); if (b.name != null) { const nm = String(b.name).replace(/\s+/g, ' ').trim().slice(0, 40); if (!nm) throw new Error('Give the calendar a name'); c.name = nm; } if (b.color != null) { if (!HEX.test(b.color)) throw new Error('Pick a color'); c.color = b.color; } if (b.shares != null) { if (!Array.isArray(b.shares)) throw new Error('Bad share list'); const seen = new Set(); c.shares = b.shares.filter(x => x && x.user !== myId() && users.get(x.user) && !seen.has(x.user) && seen.add(x.user)).map(x => ({ user: x.user, mode: x.mode === 'edit' ? 'edit' : 'view' })); } saveCals(cals); return json(res, 200, { ok: true }); } if (u.pathname === '/api/cal/ncal/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req)); const cals = calsIn(users.dir()); if (!cals.some(x => x.id === id)) throw new Error('Calendar not found'); if (cals.length < 2) throw new Error('Keep at least one NE Hub calendar'); saveCals(cals.filter(x => x.id !== id)); saveEvents(users.dir(), eventsIn(users.dir()).filter(e => evCal(e) !== id)); return json(res, 200, { ok: true }); } if (u.pathname === '/api/cal/ncal/feed' && req.method === 'POST') { // one calendar's subscribe link (reset: true makes a new one) const b = JSON.parse(await readBody(req)); const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`; return json(res, 200, { url: `${origin}/cal/feed/${calToken(b.id, !!b.reset)}.ics` }); } if (u.pathname === '/api/cal/people') // who a calendar can be shared with return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId()).map(x => ({ id: x.id, name: x.name }))); if (u.pathname === '/api/cal/color' && req.method === 'POST') { // your own color for iCloud, shared or link calendars const { id, color } = JSON.parse(await readBody(req)); if (!HEX.test(color)) throw new Error('Pick a color'); const link = load(), l = link.find(x => x.id === id); if (l) { l.color = color; save(link); } else writeJ(COLOR_FILE, { ...colorOverrides(), [id]: color }); return json(res, 200, { ok: true }); } if (u.pathname === '/api/cal/add' && req.method === 'POST') { const { name, url } = JSON.parse(await readBody(req)); if (!url) throw new Error('Paste the calendar link'); await getIcs(url); // validates the link before saving it const cals = load(); cals.push({ id: crypto.randomBytes(6).toString('hex'), name: String(name || 'Calendar').slice(0, 40), url, color: COLORS[cals.length % COLORS.length] }); save(cals); return json(res, 200, { ok: true }); } if (u.pathname === '/api/cal/remove' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req)); save(load().filter(c => c.id !== id)); return json(res, 200, { ok: true }); } if (u.pathname === '/api/cal/events') { const q = u.searchParams; const win = { from: +q.get('from'), to: +q.get('to'), fd: q.get('fd'), td: q.get('td') }; if (!win.from || !win.to || !win.fd || !win.td) throw new Error('from/to required'); return json(res, 200, await collectEvents(win)); } if (u.pathname === '/api/cal/event' && req.method === 'GET') { const q = u.searchParams, tz = q.get('tz') || 'UTC'; const r = !caldav.isCal(q.get('cal')) && resolveCal(q.get('cal')); if (r) { const e = eventsIn(r.dir).find(x => x.uid === q.get('uid') && evCal(x) === r.cid); if (!e) throw new Error('Event not found'); return json(res, 200, { cal: q.get('cal'), uid: e.uid, fields: e.fields }); } if (caldav.isCal(q.get('cal'))) { const r = await caldav.getResource(q.get('cal'), q.get('href')); const master = parseEvents(r.ics).find(e => !e.recId); if (!master) throw new Error('Could not read that event'); return json(res, 200, { cal: q.get('cal'), href: q.get('href'), etag: r.etag, fields: ics.fieldsFromEvent(master, tz) }); } return json(res, 400, { error: 'Unknown calendar' }); } if (u.pathname === '/api/cal/event' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); const r = !caldav.isCal(b.cal) && resolveCal(b.cal); if (r) { if (!canWrite(r)) throw new Error('This calendar is shared with you to view only'); ics.validate(b.fields); const all = eventsIn(r.dir), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid && evCal(x) === r.cid); const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() }; if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() }); saveEvents(r.dir, all); return json(res, 200, { ok: true, uid }); } if (caldav.isCal(b.cal)) { ics.validate(b.fields); if (b.href) { // edit: patch the latest copy so alarms, attendees etc. survive const cur = await caldav.getResource(b.cal, b.href); await caldav.putEvent(b.cal, null, ics.patchIcs(cur.ics, b.fields), { href: b.href, etag: cur.etag }); return json(res, 200, { ok: true }); } const uid = ics.newUid(); await caldav.putEvent(b.cal, uid, ics.buildCalendar([{ uid, fields: b.fields }], 'NE Hub')); return json(res, 200, { ok: true, uid }); } throw new Error('Unknown calendar'); } if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); const r = !caldav.isCal(b.cal) && resolveCal(b.cal); if (r) { if (!canWrite(r)) throw new Error('This calendar is shared with you to view only'); saveEvents(r.dir, eventsIn(r.dir).filter(x => !(x.uid === b.uid && evCal(x) === r.cid))); return json(res, 200, { ok: true }); } if (caldav.isCal(b.cal)) { await caldav.deleteEvent(b.cal, b.href, b.etag); return json(res, 200, { ok: true }); } throw new Error('Unknown calendar'); } if (u.pathname === '/api/cal/icloud' && req.method === 'GET') return json(res, 200, caldav.status()); if (u.pathname === '/api/cal/icloud/connect' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); return json(res, 200, await caldav.connect(b.appleId, b.password)); } if (u.pathname === '/api/cal/icloud/toggle' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); return json(res, 200, caldav.toggle(b.id, b.enabled)); } if (u.pathname === '/api/cal/icloud/disconnect' && req.method === 'POST') { caldav.wipe(); return json(res, 200, { ok: true }); } if (u.pathname === '/api/cal/feed') { const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`; if (req.method === 'POST') feedToken(true); // reset: the old link stops working return json(res, 200, { url: `${origin}/cal/feed/${feedToken()}.ics` }); } json(res, 404, { error: 'Not found' }); } catch (e) { json(res, 400, { error: e.message }); } } module.exports = { collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };