Repository Public
1import { lookup } from "node:dns/promises";2import net from "node:net";3import ical, { type EventInstance, type VEvent } from "node-ical";4import { sql } from "../db";5import { ApiError } from "../http";6import { decrypt, encrypt } from "../crypto";7import { audit } from "../audit";89/**10 * External calendars — V1 connector: ICS subscription (read-only free/busy).11 * Works with any calendar that publishes a private ICS address (iCloud public calendar, Google "secret address in12 * iCal format", Outlook "publish calendar"). It only READS: nothing is ever written to the external calendar.13 * Google / Microsoft OAuth connectors are not implemented (see docs/PLAN.md).14 */1516const MAX_BYTES = 8 * 1024 * 1024;17const WINDOW_PAST_DAYS = 1;18const WINDOW_FUTURE_DAYS = 400;1920function isPrivateAddress(ip: string): boolean {21 if (net.isIPv4(ip)) {22 const [a, b] = ip.split(".").map(Number);23 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;24 }25 const v = ip.toLowerCase();26 return v === "::1" || v === "::" || v.startsWith("fc") || v.startsWith("fd") || v.startsWith("fe80") || v.startsWith("::ffff:");27}2829/** Only public https addresses: the owner pastes this URL, but the server must not be usable to reach the LAN. */30export async function normalizeIcsUrl(raw: string): Promise<string> {31 let u: URL;32 try { u = new URL(raw.trim().replace(/^webcal:\/\//i, "https://")); } catch { throw new ApiError(400, "invalid_url", "This address is not a valid URL"); }33 if (u.protocol !== "https:") throw new ApiError(400, "invalid_url", "The address must start with https:// or webcal://");34 if (u.username || u.password) throw new ApiError(400, "invalid_url", "Addresses with embedded credentials are refused");35 if (process.env.NODE_ENV !== "test" || !process.env.SPCAL_ALLOW_LOCAL_ICS) {36 const addrs = net.isIP(u.hostname) ? [{ address: u.hostname }] : await lookup(u.hostname, { all: true }).catch(() => { throw new ApiError(400, "invalid_url", "This host name does not resolve"); });37 if (addrs.some((a) => isPrivateAddress(a.address))) throw new ApiError(400, "invalid_url", "Private network addresses are refused");38 }39 return u.toString();40}4142export interface BusyInterval { start: Date; end: Date; uid: string }4344/** Busy intervals of an ICS document inside the sync window. All-day events are whole days in the owner's zone. */45export function busyFromIcs(text: string, ownerTz: string, now = new Date()): BusyInterval[] {46 const from = new Date(now.getTime() - WINDOW_PAST_DAYS * 86_400_000);47 const to = new Date(now.getTime() + WINDOW_FUTURE_DAYS * 86_400_000);48 const data = ical.sync.parseICS(text);49 const out: BusyInterval[] = [];50 for (const comp of Object.values(data)) {51 if (!comp || comp.type !== "VEVENT") continue;52 const ev = comp as VEvent;53 if (ev.status === "CANCELLED" || ev.transparency === "TRANSPARENT") continue;54 if (ev.recurrenceid) continue; // overrides are applied by the expansion of their master event55 let instances: EventInstance[];56 try { instances = ical.expandRecurringEvent(ev, { from, to, expandOngoing: true }); } catch { continue; }57 for (const i of instances) {58 const st = (i.event as VEvent | undefined)?.status;59 if (st === "CANCELLED") continue;60 let start = new Date(i.start), end = new Date(i.end ?? i.start);61 if (i.isFullDay) {62 // node-ical builds date-only values at local midnight of this process; read the calendar date back and63 // place it in the owner's zone.64 const d = (x: Date) => Temporal.PlainDate.from({ year: x.getFullYear(), month: x.getMonth() + 1, day: x.getDate() });65 const s = d(start); let e = d(end);66 if (Temporal.PlainDate.compare(e, s) <= 0) e = s.add({ days: 1 });67 start = new Date(s.toZonedDateTime(ownerTz).epochMilliseconds); end = new Date(e.toZonedDateTime(ownerTz).epochMilliseconds);68 }69 if (!(end > start)) continue;70 if (end <= from || start >= to) continue;71 out.push({ start, end, uid: String(ev.uid ?? "") });72 }73 }74 return out;75}7677export async function addIcsCalendar(userId: string, input: { label: string; url: string; blocksAvailability: boolean }): Promise<string> {78 const url = await normalizeIcsUrl(input.url);79 return sql.begin(async (tx) => {80 const [a] = await tx<{ id: string }[]>`INSERT INTO external_calendar_accounts (user_id, provider, label, secret_enc) VALUES (${userId}, 'ics', ${input.label}, ${encrypt("external-calendar", url)}) RETURNING id`;81 const [c] = await tx<{ id: string }[]>`INSERT INTO external_calendars (account_id, user_id, name, blocks_availability) VALUES (${a.id}, ${userId}, ${input.label}, ${input.blocksAvailability}) RETURNING id`;82 await tx`INSERT INTO sync_state (calendar_id) VALUES (${c.id})`;83 await audit({ userId, actor: "admin", action: "external_calendar.added", entity: "external_calendar", entityId: c.id }, tx);84 return c.id;85 }) as Promise<string>;86}8788/** Disconnecting removes the stored address and every busy period that came from it. */89export async function removeCalendar(userId: string, calendarId: string): Promise<void> {90 const rows = await sql`DELETE FROM external_calendar_accounts a USING external_calendars c WHERE c.account_id = a.id AND c.id = ${calendarId} AND c.user_id = ${userId} RETURNING a.id`;91 if (rows.length === 0) throw new ApiError(404, "not_found");92 await audit({ userId, actor: "admin", action: "external_calendar.removed", entity: "external_calendar", entityId: calendarId });93}9495export interface SyncOutcome { ok: boolean; events: number; error?: string; notModified?: boolean }9697export async function syncCalendar(calendarId: string, fetchImpl: typeof fetch = fetch): Promise<SyncOutcome> {98 const [c] = await sql<{ id: string; user_id: string; account_id: string; secret_enc: Buffer; provider: string; etag: string | null; timezone: string }[]>`99 SELECT c.id, c.user_id, c.account_id, a.secret_enc, a.provider, s.etag, st.timezone100 FROM external_calendars c JOIN external_calendar_accounts a ON a.id = c.account_id101 LEFT JOIN sync_state s ON s.calendar_id = c.id JOIN settings st ON st.user_id = c.user_id102 WHERE c.id = ${calendarId} AND a.status <> 'disconnected'`;103 if (!c) return { ok: false, events: 0, error: "calendar not found" };104 await sql`INSERT INTO sync_state (calendar_id, last_attempt_at) VALUES (${c.id}, now()) ON CONFLICT (calendar_id) DO UPDATE SET last_attempt_at = now()`;105 try {106 if (c.provider !== "ics") throw new Error(`Provider ${c.provider} has no connector`);107 const url = await normalizeIcsUrl(decrypt("external-calendar", c.secret_enc));108 const res = await fetchImpl(url, { headers: { Accept: "text/calendar, text/plain;q=0.8", ...(c.etag ? { "If-None-Match": c.etag } : {}) }, redirect: "follow", signal: AbortSignal.timeout(25_000) });109 if (res.status === 304) {110 await sql`UPDATE sync_state SET last_success_at = now(), last_error = NULL, consecutive_failures = 0 WHERE calendar_id = ${c.id}`;111 await sql`UPDATE external_calendar_accounts SET status = 'connected' WHERE id = ${c.account_id}`;112 return { ok: true, events: 0, notModified: true };113 }114 if (res.status === 429) throw new Error("HTTP 429: rate limited by the calendar provider");115 if (!res.ok) throw new Error(`HTTP ${res.status}`);116 const buf = Buffer.from(await res.arrayBuffer());117 if (buf.byteLength > MAX_BYTES) throw new Error("Calendar file is larger than 8 MB");118 const text = buf.toString("utf8");119 if (!/BEGIN:VCALENDAR/i.test(text)) throw new Error("The address did not return an iCalendar file");120 const busy = busyFromIcs(text, c.timezone);121 await sql.begin(async (tx) => {122 await tx`DELETE FROM busy_blocks WHERE external_calendar_id = ${c.id}`;123 for (const b of busy) await tx`INSERT INTO busy_blocks (user_id, starts_at, ends_at, source, external_calendar_id, external_uid) VALUES (${c.user_id}, ${b.start}, ${b.end}, 'external', ${c.id}, ${b.uid.slice(0, 200)})`;124 await tx`UPDATE sync_state SET last_success_at = now(), last_error = NULL, consecutive_failures = 0, etag = ${res.headers.get("etag")}, events_count = ${busy.length} WHERE calendar_id = ${c.id}`;125 await tx`UPDATE external_calendar_accounts SET status = 'connected' WHERE id = ${c.account_id}`;126 });127 return { ok: true, events: busy.length };128 } catch (e) {129 // The last known busy periods are kept; freshness (sync_state.last_success_at) drives the conservative policy.130 const msg = (e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e)).slice(0, 300);131 await sql`UPDATE sync_state SET last_error = ${msg}, consecutive_failures = consecutive_failures + 1 WHERE calendar_id = ${c.id}`;132 await sql`UPDATE external_calendar_accounts SET status = 'error' WHERE id = ${c.account_id}`;133 return { ok: false, events: 0, error: msg };134 }135}136137/** Calendars due for a refresh; a failing calendar is retried with backoff (5 min × failures, capped at 1 h). */138export async function syncDue(intervalMin = 10): Promise<{ synced: number; failed: number }> {139 const due = await sql<{ id: string }[]>`140 SELECT c.id FROM external_calendars c JOIN external_calendar_accounts a ON a.id = c.account_id LEFT JOIN sync_state s ON s.calendar_id = c.id141 WHERE a.status <> 'disconnected'142 AND (s.last_attempt_at IS NULL OR s.last_attempt_at < now() - make_interval(mins => CASE WHEN COALESCE(s.consecutive_failures, 0) = 0 THEN ${intervalMin} ELSE LEAST(60, 5 * s.consecutive_failures) END))`;143 let synced = 0, failed = 0;144 for (const c of due) { const r = await syncCalendar(c.id); if (r.ok) synced++; else failed++; }145 return { synced, failed };146}147