Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879// cal, 26.06.15// Calendar CRUD + iCalendar (RFC 5545) feed endpoint.//// Stores one document per event in the "calendar" collection:// { user, handle, uid, title, start, end, tz, allDay, rrule,// note, visibility, when, seq }// - `user` Auth0 sub (owner key)// - `handle` denormalized @handle (or email) captured at write time// - `uid` stable id, also the iCal UID (crypto.randomUUID())// - `start`/`end` ISO UTC strings// - `tz` IANA timezone (e.g. "America/New_York")// - `allDay` boolean// - `rrule` RFC 5545 recurrence string, or null// - `visibility` "private" | "public"// - `when` server Date (creation time)// - `seq` revision integer (starts at 0, $inc on PUT)//// HTTP contract:// GET /api/cal?from=<ISO>&to=<ISO> AUTH — caller's own events overlapping [from,to]// GET /api/cal?code=<ac25xxxxx> PUBLIC — canonical per-user public feed (permahandle)// GET /api/cal?handle=<name> PUBLIC — public feed via mutable handle alias// ...add &format=ics to either public GET → text/calendar VCALENDAR body// POST /api/cal AUTH — create an event// POST /api/cal { import:{url|ics} } AUTH — import an external .ics feed// PUT /api/cal AUTH — update one of your events by uid// DELETE /api/cal?uid=<uid> AUTH — delete one of your events by uid// OPTIONS — CORS preflight//// --- Connected calendars (live, read-only overlay; "cal-feeds" collection) ---// GET /api/cal?feeds=1 AUTH — list caller's subscribed feeds (no cached events)// POST /api/cal { feed:{url,label?,color?} } AUTH — subscribe to an external .ics feed// GET /api/cal?feedEvents=1&from=&to= AUTH — live (cached) events from all feeds in [from,to]// DELETE /api/cal?feedId=<id> AUTH — unsubscribe from a feed//// Imported events get uid = "ics:" + <source UID> so a re-import upserts// (idempotent) rather than duplicating; they are always stored visibility:"private".//// SUBSCRIBE vs IMPORT: `{import:...}` COPIES events into the editable `calendar`// store once. `{feed:...}` SUBSCRIBES — it overlays an external calendar live and// read-only (events are cached in the "cal-feeds" doc, never copied into `calendar`).
import crypto from "crypto";import { authorize, getHandleOrEmail, handleFromPermahandle, userIDFromHandle,} from "../../backend/authorization.mjs";import { connect } from "../../backend/database.mjs";import { respond } from "../../backend/http.mjs";
// The only fields a stored event document is allowed to carry. Public reads// project through this list so we never leak internal/handle-doc fields.const EVENT_PROJECTION = { _id: 0, user: 1, handle: 1, uid: 1, title: 1, start: 1, end: 1, tz: 1, allDay: 1, rrule: 1, note: 1, visibility: 1, when: 1, seq: 1,};
// 📅 Default to the current calendar month when from/to are omitted.function currentMonthRange() { const now = new Date(); const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); return { from: from.toISOString(), to: to.toISOString() };}
// Build an overlap filter for [from,to] over string ISO `start`/`end` fields.// An event overlaps the window when it starts before `to` and ends after `from`.// (ISO 8601 UTC strings sort lexicographically, so plain string comparison works.)function overlapFilter(from, to) { return { start: { $lt: to }, end: { $gt: from } };}
// 🗓️ Minimal RFC 5545 serializer (no npm deps).
// Escape TEXT values per spec basics: backslash, comma, semicolon, newline.function icsEscape(value) { return String(value ?? "") .replace(/\\/g, "\\\\") .replace(/\n/g, "\\n") .replace(/,/g, "\\,") .replace(/;/g, "\\;");}
// Fold long content lines at 75 octets, continuation lines begin with a space.function icsFold(line) { if (line.length <= 75) return line; const parts = []; let rest = line; parts.push(rest.slice(0, 75)); rest = rest.slice(75); while (rest.length > 74) { parts.push(" " + rest.slice(0, 74)); rest = rest.slice(74); } if (rest.length) parts.push(" " + rest); return parts.join("\r\n");}
// Format an ISO instant as a UTC iCal date-time stamp: 20260615T143000Z.function icsDateTime(iso) { const d = new Date(iso); return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");}
// Format an ISO instant as a floating iCal date: 20260615 (for all-day events).function icsDate(iso) { const d = new Date(iso); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); return `${y}${m}${day}`;}
// Serialize a list of event docs into a VCALENDAR body.function eventsToICS(events, handleLabel) { const stamp = icsDateTime(new Date().toISOString()); const lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//aesthetic.computer//cal//EN", "CALSCALE:GREGORIAN", ]; if (handleLabel) lines.push("X-WR-CALNAME:" + icsEscape(handleLabel));
for (const ev of events) { lines.push("BEGIN:VEVENT"); lines.push("UID:" + icsEscape(ev.uid)); lines.push("DTSTAMP:" + stamp); lines.push("SEQUENCE:" + (Number.isInteger(ev.seq) ? ev.seq : 0));
if (ev.allDay) { // VALUE=DATE form — no time component, no Z. lines.push("DTSTART;VALUE=DATE:" + icsDate(ev.start)); if (ev.end) lines.push("DTEND;VALUE=DATE:" + icsDate(ev.end)); } else { lines.push("DTSTART:" + icsDateTime(ev.start)); if (ev.end) lines.push("DTEND:" + icsDateTime(ev.end)); }
if (ev.title) lines.push("SUMMARY:" + icsEscape(ev.title)); if (ev.note) lines.push("DESCRIPTION:" + icsEscape(ev.note)); if (ev.rrule) lines.push("RRULE:" + ev.rrule); // RRULE is structured, not TEXT-escaped. lines.push("END:VEVENT"); }
lines.push("END:VCALENDAR"); // Fold each line, join with CRLF per spec. return lines.map(icsFold).join("\r\n") + "\r\n";}
// 📥 Minimal RFC 5545 VCALENDAR parser (no npm deps).//// Supports, per VEVENT: UID, SUMMARY, DTSTART, DTEND, RRULE, DESCRIPTION, plus// the VALUE=DATE (all-day) and TZID=... property parameters. It does NOT do// full IANA-timezone math: a TZID date-time is read as a "floating" wall-clock// time and stamped as UTC (good enough to place an event on the right day; the// minute may be off by the source's UTC offset). `...Z` UTC and VALUE=DATE// all-day forms are exact. VTIMEZONE blocks, VALARMs, attendees, attachments,// and multi-value/structured params beyond the above are ignored.
const MAX_ICS_BYTES = 5 * 1024 * 1024; // 5MB guard.const FEED_CACHE_MS = 10 * 60 * 1000; // 10min live-feed cache TTL.
// Classify a feed URL: "google" when the host is a Google Calendar endpoint,// otherwise a generic "ics" subscription.function feedKind(url) { try { const host = new URL( String(url).replace(/^webcal:\/\//i, "https://"), ).hostname.toLowerCase(); // calendar.google.com and any *.google.com host. if (/(^|\.)google\.com$/.test(host)) return "google"; } catch { // fall through } return "ics";}
// Unfold RFC 5545 folded content lines: a line beginning with a space or tab is// a continuation of the previous line (the leading whitespace is consumed).function unfoldICS(text) { const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const out = []; for (const line of raw) { if ((line.startsWith(" ") || line.startsWith("\t")) && out.length) { out[out.length - 1] += line.slice(1); } else { out.push(line); } } return out;}
// Unescape a TEXT value per spec: \\ \, \; and \n / \N (newline).function icsUnescape(value) { return String(value ?? "") .replace(/\\n/gi, "\n") .replace(/\\,/g, ",") .replace(/\\;/g, ";") .replace(/\\\\/g, "\\");}
// Split a content line into { name, params, value }. The name+params half is// everything before the first unescaped colon; params are ;KEY=VALUE pairs.function parseICSLine(line) { const colon = line.indexOf(":"); if (colon === -1) return null; const head = line.slice(0, colon); const value = line.slice(colon + 1); const segs = head.split(";"); const name = segs[0].toUpperCase(); const params = {}; for (let i = 1; i < segs.length; i++) { const eq = segs[i].indexOf("="); if (eq === -1) continue; params[segs[i].slice(0, eq).toUpperCase()] = segs[i].slice(eq + 1); } return { name, params, value };}
// Convert a parsed DTSTART/DTEND ({ params, value }) into an ISO UTC string and// an allDay flag. Handles three forms:// VALUE=DATE → "YYYYMMDD" (all-day, midnight UTC)// "...Z" → "YYYYMMDDTHHMMSSZ" (UTC instant, exact)// TZID / bare → "YYYYMMDDTHHMMSS" (floating wall-clock → stamped UTC)function icsDateToISO(prop) { if (!prop || !prop.value) return null; const v = prop.value.trim(); const isDateValue = prop.params?.VALUE === "DATE" || /^\d{8}$/.test(v);
if (isDateValue) { const m = /^(\d{4})(\d{2})(\d{2})$/.exec(v); if (!m) return null; const iso = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], 0, 0, 0)).toISOString(); return { iso, allDay: true }; }
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v); if (!m) return null; // Both UTC (`Z`) and floating/TZID times are placed onto the UTC clock. For // floating/TZID we accept the wall-clock minute as-is (no offset table). const iso = new Date( Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]), ).toISOString(); return { iso, allDay: false };}
// Parse a whole VCALENDAR body into an array of normalized event objects:// { sourceUID, title, start, end, allDay, rrule, note }// Events missing a UID or a start are returned with those fields null so the// caller can count them as skipped.function parseVCalendar(text) { const lines = unfoldICS(text); const events = []; let cur = null;
for (const line of lines) { const upper = line.toUpperCase(); if (upper === "BEGIN:VEVENT") { cur = { sourceUID: null, title: "", dtstart: null, dtend: null, rrule: null, note: "", }; continue; } if (upper === "END:VEVENT") { if (cur) { const start = icsDateToISO(cur.dtstart); const end = icsDateToISO(cur.dtend); events.push({ sourceUID: cur.sourceUID, title: cur.title, start: start?.iso || null, end: end?.iso || null, allDay: start?.allDay || false, rrule: cur.rrule, note: cur.note, }); } cur = null; continue; } if (!cur) continue;
const parsed = parseICSLine(line); if (!parsed) continue; switch (parsed.name) { case "UID": cur.sourceUID = parsed.value.trim(); break; case "SUMMARY": cur.title = icsUnescape(parsed.value); break; case "DESCRIPTION": cur.note = icsUnescape(parsed.value); break; case "DTSTART": cur.dtstart = parsed; break; case "DTEND": cur.dtend = parsed; break; case "RRULE": cur.rrule = parsed.value.trim(); break; default: break; } } return events;}
// Fetch + sanity-check an external .ics feed. Accepts webcal:// (rewritten to// https://). Rejects non-text or oversized payloads. Returns the raw text.async function fetchICS(url) { let target = String(url).trim(); if (/^webcal:\/\//i.test(target)) target = target.replace(/^webcal:\/\//i, "https://"); if (!/^https?:\/\//i.test(target)) { throw new Error("Import URL must be http(s) or webcal."); }
const res = await fetch(target, { headers: { Accept: "text/calendar, text/plain, */*" }, redirect: "follow", }); if (!res.ok) throw new Error(`Fetch failed (${res.status}).`);
const len = Number(res.headers.get("content-length") || 0); if (len && len > MAX_ICS_BYTES) throw new Error("Calendar too large (>5MB).");
const text = await res.text(); if (text.length > MAX_ICS_BYTES) throw new Error("Calendar too large (>5MB)."); if (!/BEGIN:VCALENDAR/i.test(text)) { throw new Error("Not an iCalendar (no VCALENDAR) document."); } return text;}
// Upsert parsed events into the `calendar` collection, keyed by { user, uid }// where uid = "ics:" + sourceUID. Idempotent: re-importing updates in place.async function importEvents(collection, sub, handle, parsed) { let imported = 0; let updated = 0; let skipped = 0;
for (const ev of parsed) { if (!ev.sourceUID || !ev.start) { skipped++; continue; } const uid = "ics:" + ev.sourceUID; const result = await collection.updateOne( { user: sub, uid }, { $set: { handle: handle || null, title: ev.title || "(untitled)", start: ev.start, end: ev.end || ev.start, allDay: ev.allDay === true, rrule: ev.rrule || null, note: ev.note || "", visibility: "private", when: new Date(), }, $setOnInsert: { user: sub, uid, seq: 0 }, }, { upsert: true }, ); if (result.upsertedCount > 0) imported++; else updated++; }
return { imported, updated, skipped };}
// ── Secret subscription token ────────────────────────────────────────────// A per-user secret stored on the `users` doc (`calFeedToken`). Whoever holds// it can read the user's FULL calendar (private included) over the public ICS// endpoint — the AesthetiCal equivalent of Google's "secret address in iCal// format". Revocable by rotating. NEVER logged.function newCalToken() { return crypto.randomBytes(24).toString("hex"); // 48 hex chars}
// Read (minting on first use) the caller's secret token.async function ensureCalToken(database, sub) { const users = database.db.collection("users"); const u = await users.findOne({ _id: sub }, { projection: { calFeedToken: 1 } }); if (u?.calFeedToken) return u.calFeedToken; const token = newCalToken(); await users.updateOne({ _id: sub }, { $set: { calFeedToken: token } }); return token;}
// Replace the secret (invalidates every existing subscription URL).async function rotateCalToken(database, sub) { const token = newCalToken(); await database.db .collection("users") .updateOne({ _id: sub }, { $set: { calFeedToken: token } }); return token;}
// Constant-time check of a provided token against the user's stored secret.async function verifyCalToken(database, sub, provided) { if (typeof provided !== "string" || !provided) return false; const u = await database.db .collection("users") .findOne({ _id: sub }, { projection: { calFeedToken: 1 } }); const expected = u?.calFeedToken; if (typeof expected !== "string" || !expected) return false; const a = Buffer.from(provided); const b = Buffer.from(expected); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b);}
// The caller's permahandle code (for building their subscription URL).async function permahandleFor(database, sub) { const u = await database.db .collection("users") .findOne({ _id: sub }, { projection: { code: 1 } }); return u?.code || null;}
export async function handler(event, context) { // CORS preflight. if (event.httpMethod === "OPTIONS") { return respond(200, "", { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", }); }
const method = event.httpMethod; if (!["GET", "POST", "PUT", "DELETE"].includes(method)) { return respond(405, { message: "Method Not Allowed" }); }
const params = event.queryStringParameters || {}; const database = await connect();
try { const collection = database.db.collection("calendar");
// Create indexes once (best-effort). await collection.createIndex({ user: 1, start: 1 }).catch(() => {}); await collection .createIndex({ user: 1, uid: 1 }, { unique: true }) .catch(() => {}); await database.db .collection("cal-feeds") .createIndex({ user: 1, id: 1 }, { unique: true }) .catch(() => {});
// ───────────────────────────────────────────────────────────── GET ── if (method === "GET") { const code = params.code; const handleParam = params.handle; const wantsICS = params.format === "ics";
// 🌍 PUBLIC feed (by permahandle code or by handle alias). if (code || handleParam) { let sub; let handleStr;
if (code) { // Canonical per-user feed via permahandle (ac25xxxxx). const resolved = await handleFromPermahandle(code, database); if (!resolved) { return respond(404, { message: "Not found." }); } sub = resolved.sub; handleStr = "@" + resolved.handle; } else { // Mutable handle alias → sub. const clean = handleParam.replace(/^@/, ""); sub = await userIDFromHandle(clean, database); if (!sub) { return respond(404, { message: "Not found." }); } handleStr = "@" + clean; }
// By default public reads NEVER return private events. A valid secret // subscription token (the user's `calFeedToken`) unlocks the FULL // calendar — this is what an external calendar app subscribes to, since // it can carry a secret in the URL but not a login bearer. let query = { user: sub, visibility: "public" }; if (params.token) { const ok = await verifyCalToken(database, sub, params.token); if (!ok) return respond(403, { message: "Invalid subscription token." }); query = { user: sub }; // full calendar (private + public) }
const events = await collection .find(query) .project(EVENT_PROJECTION) .sort({ start: 1 }) .toArray();
if (wantsICS) { return respond(200, eventsToICS(events, handleStr), { "Content-Type": "text/calendar; charset=utf-8", }); }
return respond(200, { handle: handleStr, code: code || null, events }); }
// 🔐 AUTH feed (caller's own events overlapping [from,to]). const user = await authorize(event.headers); if (!user?.sub) { return respond(401, { message: "Authorization failure." }); }
// 🔑 Subscription-link branch: return (minting on first use) the caller's // secret token + the ready-to-paste AesthetiCal ICS subscription URL. if (params.calToken) { const token = await ensureCalToken(database, user.sub); const code = await permahandleFor(database, user.sub); const base = "https://aesthetic.computer/api/cal"; const url = code ? `${base}?code=${code}&token=${token}&format=ics` : null; return respond(200, { token, code, url }); }
// 🔗 Connected-calendar GET branches. Distinguished from the normal range // read by the presence of the `feeds` / `feedEvents` query flags. if (params.feeds) { const feedsCol = database.db.collection("cal-feeds"); const feeds = await feedsCol .find({ user: user.sub }) .project({ _id: 0, id: 1, label: 1, url: 1, color: 1, kind: 1 }) .toArray(); return respond(200, { feeds }); }
if (params.feedEvents) { const feedsCol = database.db.collection("cal-feeds"); const feeds = await feedsCol.find({ user: user.sub }).toArray();
let { from, to } = params; if (!from || !to) { const range = currentMonthRange(); from = from || range.from; to = to || range.to; }
const out = []; const errors = []; const now = Date.now();
for (const feed of feeds) { try { let cached = feed.cachedEvents; const fresh = feed.cachedAt && now - new Date(feed.cachedAt).getTime() < FEED_CACHE_MS && Array.isArray(cached);
if (!fresh) { const text = await fetchICS(feed.url); const parsed = parseVCalendar(text); cached = parsed .filter((ev) => ev.start) .map((ev) => ({ uid: ev.sourceUID || crypto.randomUUID(), title: ev.title || "(untitled)", start: ev.start, end: ev.end || ev.start, allDay: ev.allDay === true, note: ev.note || "", })); await feedsCol.updateOne( { user: user.sub, id: feed.id }, { $set: { cachedEvents: cached, cachedAt: new Date() } }, ); }
for (const ev of cached) { // Overlap test against [from,to] (string ISO compare). const evEnd = ev.end || ev.start; if (ev.start < to && evEnd > from) { out.push({ uid: ev.uid, title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay === true, note: ev.note || "", source: feed.id, label: feed.label || null, color: feed.color || null, readOnly: true, }); } } } catch (err) { errors.push({ id: feed.id, label: feed.label || null, message: err?.message || String(err), }); } }
return respond(200, { events: out, errors }); }
let { from, to } = params; if (!from || !to) { const range = currentMonthRange(); from = from || range.from; to = to || range.to; }
const events = await collection .find({ user: user.sub, ...overlapFilter(from, to) }) .project(EVENT_PROJECTION) .sort({ start: 1 }) .toArray();
return respond(200, { events }); }
// ──────────────────────────────────────────────────────────── POST ── if (method === "POST") { const user = await authorize(event.headers); if (!user?.sub) { return respond(401, { message: "Authorization failure." }); }
let body; try { body = JSON.parse(event.body || "{}"); } catch { return respond(400, { message: "Invalid JSON body." }); }
// 🔑 Rotate the secret subscription token (invalidates old links). if (body.rotateToken === true) { const token = await rotateCalToken(database, user.sub); const code = await permahandleFor(database, user.sub); const base = "https://aesthetic.computer/api/cal"; const url = code ? `${base}?code=${code}&token=${token}&format=ics` : null; return respond(200, { token, code, url }); }
// 🔗 Subscribe branch: { feed: { url, label?, color? } }. Detected before // import/create so a subscribe request never falls through. Unlike import, // this overlays the feed live + read-only (cached in "cal-feeds"). if (body.feed && typeof body.feed === "object") { const { url, label, color } = body.feed; if (typeof url !== "string" || !url.trim()) { return respond(400, { message: "Provide feed.url." }); }
let text; try { text = await fetchICS(url); // Validates fetchable + has BEGIN:VCALENDAR. } catch (err) { return respond(400, { message: err?.message || "Feed fetch failed." }); }
// Prime the cache with the first parse so the first read is instant. const parsed = parseVCalendar(text); const cachedEvents = parsed .filter((ev) => ev.start) .map((ev) => ({ uid: ev.sourceUID || crypto.randomUUID(), title: ev.title || "(untitled)", start: ev.start, end: ev.end || ev.start, allDay: ev.allDay === true, note: ev.note || "", }));
const feedsCol = database.db.collection("cal-feeds"); const id = crypto.randomUUID(); const kind = feedKind(url); const doc = { _id: undefined, user: user.sub, id, label: typeof label === "string" && label.trim() ? label : "Calendar", url: url.trim(), color: typeof color === "string" && color.trim() ? color : null, kind, cachedEvents, cachedAt: new Date(), }; delete doc._id; await feedsCol.insertOne(doc);
return respond(200, { feed: { id, label: doc.label, url: doc.url, color: doc.color, kind }, }); }
// 📥 Import branch: { import: { url?, ics? } }. Detected before the normal // create path so an import request never falls through to event creation. if (body.import && typeof body.import === "object") { const { url, ics } = body.import; let text; try { if (typeof ics === "string" && ics.trim()) { if (ics.length > MAX_ICS_BYTES) { return respond(400, { message: "Calendar too large (>5MB)." }); } if (!/BEGIN:VCALENDAR/i.test(ics)) { return respond(400, { message: "Not an iCalendar (no VCALENDAR) document.", }); } text = ics; } else if (typeof url === "string" && url.trim()) { text = await fetchICS(url); } else { return respond(400, { message: "Provide import.url or import.ics." }); } } catch (err) { return respond(400, { message: err?.message || "Import failed." }); }
const importHandle = await getHandleOrEmail(user.sub); const parsed = parseVCalendar(text); const counts = await importEvents( collection, user.sub, importHandle, parsed, ); return respond(200, counts); }
if (!body.title || !body.start || !body.end) { return respond(400, { message: "Missing title, start, or end." }); }
const handle = await getHandleOrEmail(user.sub);
const doc = { user: user.sub, handle: handle || null, uid: crypto.randomUUID(), title: body.title, start: body.start, end: body.end, tz: body.tz || null, allDay: body.allDay === true, rrule: body.rrule || null, note: body.note || "", visibility: body.visibility === "public" ? "public" : "private", when: new Date(), seq: 0, };
await collection.insertOne(doc);
// Return the event with the same projection shape (no _id). const { _id, ...eventOut } = doc; // eslint-disable-line no-unused-vars return respond(200, { event: eventOut }); }
// ───────────────────────────────────────────────────────────── PUT ── if (method === "PUT") { const user = await authorize(event.headers); if (!user?.sub) { return respond(401, { message: "Authorization failure." }); }
let body; try { body = JSON.parse(event.body || "{}"); } catch { return respond(400, { message: "Invalid JSON body." }); }
if (!body.uid) { return respond(400, { message: "Missing uid." }); }
// Only allow updating known mutable fields — never user/handle/uid/when/seq. const updatable = [ "title", "start", "end", "tz", "allDay", "rrule", "note", "visibility", ]; const set = {}; for (const key of updatable) { if (body[key] === undefined) continue; if (key === "allDay") set.allDay = body.allDay === true; else if (key === "visibility") set.visibility = body.visibility === "public" ? "public" : "private"; else set[key] = body[key]; }
// Update ONLY the caller's own event; bump revision; return the new doc. const result = await collection.findOneAndUpdate( { user: user.sub, uid: body.uid }, { $set: set, $inc: { seq: 1 } }, { returnDocument: "after", projection: EVENT_PROJECTION }, );
// Driver compatibility: some versions wrap in { value }, others return the doc. const updated = result?.value ?? result; if (!updated || !updated.uid) { return respond(404, { message: "Not found." }); }
return respond(200, { event: updated }); }
// ────────────────────────────────────────────────────────── DELETE ── if (method === "DELETE") { const user = await authorize(event.headers); if (!user?.sub) { return respond(401, { message: "Authorization failure." }); }
// 🔗 Unsubscribe branch: ?feedId=<id>. Distinguished from event delete by // the `feedId` query param (vs `uid`); removes from "cal-feeds" only. if (params.feedId) { const feedsCol = database.db.collection("cal-feeds"); const fres = await feedsCol.deleteOne({ user: user.sub, id: params.feedId, }); if (fres.deletedCount === 0) { return respond(404, { message: "Not found." }); } return respond(200, { ok: true }); }
const uid = params.uid; if (!uid) { return respond(400, { message: "Missing uid." }); }
const result = await collection.deleteOne({ user: user.sub, uid }); if (result.deletedCount === 0) { return respond(404, { message: "Not found." }); }
return respond(200, { ok: true }); } } catch (error) { return respond(500, { message: error?.message || String(error) }); } finally { await database.disconnect(); }}