diff --git a/src/servers/sidecar/caldav/collections.ts b/src/servers/sidecar/caldav/collections.ts new file mode 100644 index 00000000..2af73725 --- /dev/null +++ b/src/servers/sidecar/caldav/collections.ts @@ -0,0 +1,130 @@ +import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical'; + +// The JSON door: what Officer's own web UI reads. +// +// This talks DAV to Radicale over loopback rather than reading Radicale's on-disk format. The storage +// layout is Radicale's private business and changes between versions; PROPFIND is its supported +// interface and costs one in-process HTTP hop. Parsing the storage directly would be faster and would +// break silently on an upgrade, which is a bad trade for a personal calendar. +// +// The browser never sees any of this XML — that is the entire point of having a JSON door. See +// docs/nextcloud-replacement.md, "two front doors, not one". + +export type Collection = { + /** DAV path, relative to the /dav mount — the id the UI passes back. */ + path: string; + displayName: string; + kind: 'calendar' | 'addressbook'; + /** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */ + color?: string; +}; + +type Fetcher = (path: string, init: RequestInit) => Promise; + +// Deliberately not a real XML parser. The only documents read here are Radicale's own multistatus +// responses — a narrow, machine-generated shape, not arbitrary user XML — and adding a parser +// dependency to pull four fields out of it is not worth the weight. If this ever needs to read XML +// from somewhere less predictable, it should grow a parser rather than more regexes. +const between = (xml: string, tag: string): string[] => { + const out: string[] = []; + const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tag}[^>]*>([\\s\\S]*?)`, 'g'); + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) out.push(m[1] ?? ''); + return out; +}; + +const first = (xml: string, tag: string): string | undefined => between(xml, tag)[0]; + +const decodeEntities = (s: string) => + s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + +const PROPFIND_COLLECTIONS = ` + + +`; + +/** Every calendar and address book belonging to this user. */ +export async function listCollections(fetcher: Fetcher, userId: string): Promise { + const res = await fetcher(`/${userId}/`, { + method: 'PROPFIND', + headers: { Depth: '1', 'Content-Type': 'application/xml' }, + body: PROPFIND_COLLECTIONS, + }); + if (!res.ok && res.status !== 207) return []; + const xml = await res.text(); + + const collections: Collection[] = []; + for (const response of between(xml, 'response')) { + const href = first(response, 'href'); + if (!href) continue; + + // The tag must END here — `\b` alone also matches ``, which the principal + // carries, and that made the principal itself show up in the list as a calendar named "1". + const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\s*\/>/.test(response); + const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\s*\/>/.test(response); + if (!isCalendar && !isAddressbook) continue; // the principal itself, or a plain collection + + const path = decodeEntities(href.trim()); + const displayName = decodeEntities((first(response, 'displayname') ?? '').trim()); + const color = (first(response, 'calendar-color') ?? '').trim() || undefined; + + const lastSegment = path.replace(/\/$/, '').split('/').pop() || 'Untitled'; + + collections.push({ + path, + // Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a + // name. A real client sets something human when it creates a collection; until one does, the last + // segment is the better label — and it is what every DAV client falls back to anyway. + displayName: displayName && !displayName.includes('/') ? displayName : lastSegment, + kind: isCalendar ? 'calendar' : 'addressbook', + color, + }); + } + return collections; +} + +/** + * Every object in one collection, in one round trip. + * + * REPORT, not PROPFIND. `calendar-data` and `address-data` are not WebDAV live properties — RFC 4791 and + * RFC 6352 define them as report-only, and Radicale correctly returns an empty prop for them under + * PROPFIND. Asking the wrong way produces a 207 full of nothing, which reads exactly like "the calendar + * is empty" and is a genuinely confusing way to be wrong. + * + * One report rather than a listing plus a GET per item: a calendar with a thousand events would + * otherwise be a thousand round trips. + */ +async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'addressbook'): Promise { + const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data'; + const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav'; + const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query'; + // An empty filter means "everything". A calendar-query with no comp-filter is legal and is the + // cheapest way to say it. + const filter = kind === 'calendar' ? '' : ''; + + const res = await fetcher(path, { + method: 'REPORT', + headers: { Depth: '1', 'Content-Type': 'application/xml' }, + body: `${filter}`, + }); + if (!res.ok && res.status !== 207) return []; + const xml = await res.text(); + return between(xml, dataTag) + .map((chunk) => decodeEntities(chunk).trim()) + .filter(Boolean); +} + +export async function listEvents(fetcher: Fetcher, path: string): Promise { + const blobs = await readObjects(fetcher, path, 'calendar'); + return blobs.flatMap(parseEvents); +} + +export async function listContacts(fetcher: Fetcher, path: string): Promise { + const blobs = await readObjects(fetcher, path, 'addressbook'); + return blobs.flatMap(parseContacts); +} diff --git a/src/servers/sidecar/caldav/ical.ts b/src/servers/sidecar/caldav/ical.ts new file mode 100644 index 00000000..90901433 --- /dev/null +++ b/src/servers/sidecar/caldav/ical.ts @@ -0,0 +1,202 @@ +// Just enough iCalendar/vCard to drive a list view. +// +// SCOPE, DELIBERATELY SMALL. This unfolds lines, splits properties and pulls out the handful of fields a +// UI shows. It does NOT expand RRULE, and it does not resolve VTIMEZONE — see +// docs/nextcloud-replacement.md for why those are the expensive half and why Radicale, not this file, +// owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us +// pretending to know when. +// +// Everything here is display-only. The authoritative copy of an event is the .ics on disk that DAV +// clients read and write; nothing in this file ever writes one back. + +/** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */ +function unfold(text: string): string[] { + const out: string[] = []; + for (const raw of text.split(/\r?\n/)) { + if ((raw.startsWith(' ') || raw.startsWith('\t')) && out.length > 0) { + out[out.length - 1] += raw.slice(1); + } else if (raw.length > 0) { + out.push(raw); + } + } + return out; +} + +export type Prop = { name: string; params: Record; value: string }; + +/** `DTSTART;TZID=Europe/Lisbon:20260805T090000` → name, params, value. */ +function parseLine(line: string): Prop | null { + const colon = line.indexOf(':'); + if (colon < 0) return null; + const head = line.slice(0, colon); + const value = line.slice(colon + 1); + const [name, ...paramParts] = head.split(';'); + const params: Record = {}; + for (const part of paramParts) { + const eq = part.indexOf('='); + if (eq > 0) params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1).replace(/^"|"$/g, ''); + } + return { name: (name ?? '').toUpperCase(), params, value }; +} + +// Escaped per RFC 5545 §3.3.11. Order matters: backslash last, or it would un-escape the escapes. +const unescapeText = (value: string) => + value.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\'); + +/** + * iCalendar dates come in three shapes and conflating them is how an all-day event ends up on the wrong + * day: `20260805` (date only), `20260805T090000Z` (UTC) and `20260805T090000` with a TZID parameter + * (floating/local). Only the first two can be resolved here without a timezone database, so the third is + * returned as-is with `allDay:false` and the UI treats it as local — which is what it means to whoever + * typed it in. + */ +export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null { + const v = prop.value.trim(); + const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v); + if (dateOnly) return { iso: `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`, allDay: true }; + + const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v); + if (!dateTime) return null; + const [, y, mo, d, h, mi, s, z] = dateTime; + return { iso: `${y}-${mo}-${d}T${h}:${mi}:${s}${z ? 'Z' : ''}`, allDay: false }; +} + +export type CalendarEvent = { + uid: string; + summary: string; + description?: string; + location?: string; + start?: string; + end?: string; + allDay: boolean; + /** Raw RRULE, unexpanded. Present means "this repeats"; the UI must not infer occurrences from it. */ + rrule?: string; + status?: string; +}; + +/** Every VEVENT in one .ics. A file usually holds one, plus overrides for a recurring series. */ +export function parseEvents(ics: string): CalendarEvent[] { + const events: CalendarEvent[] = []; + let current: CalendarEvent | null = null; + // Only VEVENT is collected; a VALARM or VTIMEZONE nested inside must not have its properties + // attributed to the event that contains it. + let depth: string[] = []; + + for (const line of unfold(ics)) { + const prop = parseLine(line); + if (!prop) continue; + + if (prop.name === 'BEGIN') { + depth.push(prop.value.toUpperCase()); + if (prop.value.toUpperCase() === 'VEVENT') current = { uid: '', summary: '', allDay: false }; + continue; + } + if (prop.name === 'END') { + if (prop.value.toUpperCase() === 'VEVENT' && current) { + events.push(current); + current = null; + } + depth.pop(); + continue; + } + if (!current || depth[depth.length - 1] !== 'VEVENT') continue; + + switch (prop.name) { + case 'UID': + current.uid = prop.value; + break; + case 'SUMMARY': + current.summary = unescapeText(prop.value); + break; + case 'DESCRIPTION': + current.description = unescapeText(prop.value); + break; + case 'LOCATION': + current.location = unescapeText(prop.value); + break; + case 'STATUS': + current.status = prop.value; + break; + case 'RRULE': + current.rrule = prop.value; + break; + case 'DTSTART': { + const parsed = parseDate(prop); + if (parsed) { + current.start = parsed.iso; + current.allDay = parsed.allDay; + } + break; + } + case 'DTEND': { + const parsed = parseDate(prop); + if (parsed) current.end = parsed.iso; + break; + } + } + } + return events; +} + +export type Contact = { + uid: string; + fullName: string; + emails: string[]; + phones: string[]; + org?: string; + title?: string; + /** Present when the card carries an embedded photo; the bytes are not inlined into list responses. */ + hasPhoto: boolean; +}; + +/** Every VCARD in one .vcf. */ +export function parseContacts(vcf: string): Contact[] { + const contacts: Contact[] = []; + let current: Contact | null = null; + + for (const line of unfold(vcf)) { + const prop = parseLine(line); + if (!prop) continue; + + if (prop.name === 'BEGIN' && prop.value.toUpperCase() === 'VCARD') { + current = { uid: '', fullName: '', emails: [], phones: [], hasPhoto: false }; + continue; + } + if (prop.name === 'END' && prop.value.toUpperCase() === 'VCARD') { + if (current) contacts.push(current); + current = null; + continue; + } + if (!current) continue; + + switch (prop.name) { + case 'UID': + current.uid = prop.value; + break; + case 'FN': + current.fullName = unescapeText(prop.value); + break; + case 'EMAIL': + current.emails.push(prop.value.trim()); + break; + case 'TEL': + current.phones.push(prop.value.trim()); + break; + case 'ORG': + // ORG is structured: `Company;Department`. The first component is the one a list shows. + current.org = unescapeText(prop.value.split(';')[0] ?? ''); + break; + case 'TITLE': + current.title = unescapeText(prop.value); + break; + case 'PHOTO': + current.hasPhoto = true; + break; + } + } + // A card with no FN is legal but unshowable; fall back to the first email so it is at least findable. + for (const contact of contacts) { + if (!contact.fullName) contact.fullName = contact.emails[0] ?? contact.uid ?? 'Unnamed'; + } + return contacts; +} diff --git a/src/servers/sidecar/caldav/index.ts b/src/servers/sidecar/caldav/index.ts index fb8904ba..4845083b 100644 --- a/src/servers/sidecar/caldav/index.ts +++ b/src/servers/sidecar/caldav/index.ts @@ -1,6 +1,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { startRadicale, davPaths } from './radicale'; +import { listCollections, listEvents, listContacts } from './collections'; // The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the // collection storage under DATA_PATH/dav, and exposes two very different doors. @@ -153,6 +154,43 @@ const server = Bun.serve({ return forwardToRadicale(req, subpath, String(userId)); } + // The JSON door. Same data, no XML: a month view built out of multistatus responses would make the + // web UI as fragile as the protocol. + if (url.pathname.startsWith('/_officer/')) { + // Radicale generates hrefs prefixed with /dav (X-Script-Name), and the UI hands those back + // verbatim as collection ids — so strip the prefix again on the way in. + const fetcher = (path: string, init: RequestInit) => + fetch(`${radicale.url}${path.replace(/^\/dav/, '')}`, { + ...init, + headers: { + ...(init.headers as Record), + 'X-Remote-User': String(userId), + // Same prefix the phone-facing door sends, so the collection ids the UI receives here are + // the same strings a DAV client would see. Without it the UI gets /1/work/ and the phone + // gets /dav/1/work/ for the same collection, and nothing downstream can match them up. + 'X-Script-Name': DAV_MOUNT, + }, + }); + + if (url.pathname === '/_officer/collections') { + return Response.json({ collections: await listCollections(fetcher, String(userId)) }); + } + + const path = url.searchParams.get('collection'); + // Confine reads to this user's own tree. The header already scopes Radicale, but a path from a + // query string is attacker-shaped input and should not be handed on unchecked. + if (!path || !path.startsWith(`/dav/${userId}/`)) { + return Response.json({ error: 'collection is required' }, { status: 400 }); + } + + if (url.pathname === '/_officer/events') { + return Response.json({ events: await listEvents(fetcher, path) }); + } + if (url.pathname === '/_officer/contacts') { + return Response.json({ contacts: await listContacts(fetcher, path) }); + } + } + return new Response('not found', { status: 404 }); }, });