SPB Git

Repository Public

external.ts 9.6 KB 147 lines typescript

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
Files main
Menu

Theme