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:
@@ -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