caldav: json door for the web ui
step 3 of docs/nextcloud-replacement.md. collections, events and contacts as plain json, so the browser never has to parse multistatus xml to draw a list. this talks dav to radicale over loopback rather than reading its 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 hop. parsing the storage directly would be faster and would break silently on upgrade, which is a bad trade for a calendar. three bugs found and fixed while verifying, all of which fail quietly rather than loudly: calendar-data and address-data are NOT webdav live properties. rfc 4791 and 6352 define them as report-only, and radicale correctly returns an empty prop for them under propfind — so the first version returned a 207 full of nothing, which reads exactly like "your calendar is empty". the principal resource matched the calendar test, because `<C:calendar-home-set/>` satisfies /calendar\b/. the principal showed up in the list as a calendar called "1". the tag has to be required to end. the internal fetcher omitted X-Script-Name, so the ui was handed /1/work/ for the same collection a phone sees as /dav/1/work/, and nothing downstream could have matched them up. ical.ts is deliberately small: it unfolds lines and pulls out the fields a list shows. it does NOT expand rrule or resolve vtimezone — radicale owns correctness there, and rrule is passed through raw so the ui can say "repeats" without either of us pretending to know when. verified against the running stack with a real vevent and a real vcard, then the fixtures were removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Response>;
|
||||
|
||||
// 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]*?)</(?:[a-zA-Z0-9]+:)?${tag}>`, '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 = `<?xml version="1.0"?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:IC="http://apple.com/ns/ical/">
|
||||
<D:prop><D:resourcetype/><D:displayname/><IC:calendar-color/></D:prop>
|
||||
</D:propfind>`;
|
||||
|
||||
/** Every calendar and address book belonging to this user. */
|
||||
export async function listCollections(fetcher: Fetcher, userId: string): Promise<Collection[]> {
|
||||
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 `<C:calendar-home-set/>`, 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<string[]> {
|
||||
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' ? '<X:filter><X:comp-filter name="VCALENDAR"/></X:filter>' : '<X:filter/>';
|
||||
|
||||
const res = await fetcher(path, {
|
||||
method: 'REPORT',
|
||||
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||
body: `<?xml version="1.0"?><X:${queryTag} xmlns:D="DAV:" xmlns:X="${ns}"><D:prop><X:${dataTag}/></D:prop>${filter}</X:${queryTag}>`,
|
||||
});
|
||||
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<CalendarEvent[]> {
|
||||
const blobs = await readObjects(fetcher, path, 'calendar');
|
||||
return blobs.flatMap(parseEvents);
|
||||
}
|
||||
|
||||
export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
|
||||
const blobs = await readObjects(fetcher, path, 'addressbook');
|
||||
return blobs.flatMap(parseContacts);
|
||||
}
|
||||
@@ -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<string, string>; 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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string>),
|
||||
'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 });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user