import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { startRadicale, davPaths } from './radicale'; import { listCollections, listEvents, listContacts } from './collections'; import { DATA_PATH } from '../../data-path'; import { API_URL } from '../../officer-url.mjs'; // 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. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its mount prefix before forwarding. // // ANY /dav/* the DAV door. Forwarded verbatim to Radicale, including PROPFIND, REPORT, // MKCOL, MKCALENDAR, COPY, MOVE, LOCK and UNLOCK, and every DAV header. // Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV // app password over HTTP Basic. This is the door phones use. // GET /_health is Radicale up, and does it answer. // GET /_officer/* the JSON door for Officer's own web UI (see collections.ts). // // WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a // lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on // local disk here, so this sidecar can serve the UI plain JSON over the same data while DAV stays the // machine-facing interface. Same split officer-email already uses. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } // Where the platform exposes the DAV door publicly. It is a fixed contract between hono.ts's top-level // mount and this sidecar, not configuration — Radicale has to be told it so the hrefs it generates are // the ones the client can actually fetch. const DAV_MOUNT = '/dav'; const paths = davPaths(DATA_PATH); const radicale = startRadicale({ ...paths, port: getFreePort() }); const port = getFreePort(); // Headers a DAV exchange cannot survive without. `Depth` alone decides whether a PROPFIND returns the // collection, its children, or the whole tree — drop it and every client sees an empty calendar. The // rest carry conditional writes (If-Match is how a client avoids clobbering a concurrent edit), // COPY/MOVE targets, and lock tokens. const DAV_REQUEST_HEADERS = [ 'depth', 'content-type', 'content-length', 'if', 'if-match', 'if-none-match', 'destination', 'overwrite', 'lock-token', 'timeout', 'prefer', 'user-agent', ] as const; // Sent back untouched. `DAV` and `Allow` are how a client discovers what the server supports; getting // them wrong makes iOS decide the account is not a calendar at all. const DAV_RESPONSE_HEADERS = [ 'content-type', 'dav', 'allow', 'etag', 'last-modified', 'location', 'lock-token', 'preference-applied', 'vary', ] as const; async function forwardToRadicale(req: Request, subpath: string, userId: string): Promise { const url = new URL(req.url); const target = `${radicale.url}${subpath}${url.search}`; const headers: Record = {}; for (const name of DAV_REQUEST_HEADERS) { const value = req.headers.get(name); if (value) headers[name] = value; } // Radicale is configured with `type = http_x_remote_user` and does no authentication of its own. It // binds loopback, so this header is only settable from inside this process. headers['X-Remote-User'] = userId; // Radicale generates ABSOLUTE hrefs in its multistatus bodies, and it builds them from where it // thinks it is mounted. Without this it answers a PROPFIND on /dav/ with // `/1/`, the client dutifully requests /1/, and the platform // serves it the SPA — an HTML page where a calendar was expected. The client does not report a // useful error for that; the account just appears to have no calendars in it. headers['X-Script-Name'] = DAV_MOUNT; const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; let upstream: Response; try { upstream = await fetch(target, { method: req.method, headers, body: hasBody ? await req.arrayBuffer() : undefined, }); } catch (err) { // Path only — a DAV body is the owner's calendar and address book content. console.error('[caldav] radicale unreachable', { path: subpath, error: String(err) }); return new Response('caldav upstream unreachable', { status: 502 }); } const out = new Headers(); for (const name of DAV_RESPONSE_HEADERS) { const value = upstream.headers.get(name); if (value) out.set(name, value); } return new Response(upstream.body, { status: upstream.status, headers: out }); } const server = Bun.serve({ port, hostname: '127.0.0.1', // A contact photo or a calendar with years of history arrives as one PUT. maxRequestBodySize: 32 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return new Response('missing or invalid X-Officer-User', { status: 401 }); } if (url.pathname === '/_health') { if (!radicale.isRunning()) { return Response.json({ ok: false, running: false, error: 'radicale not running' }, { status: 503 }); } try { const probe = await fetch(`${radicale.url}/`, { method: 'PROPFIND', headers: { 'X-Remote-User': String(userId), Depth: '0' }, signal: AbortSignal.timeout(5000), }); // Anything that answers the DAV handshake is healthy; 207 Multi-Status is the expected reply. return Response.json({ ok: probe.status < 500, running: true, status: probe.status }); } catch (err) { return Response.json({ ok: false, running: true, error: String(err) }, { status: 502 }); } } if (url.pathname === '/dav' || url.pathname.startsWith('/dav/')) { const subpath = url.pathname.slice('/dav'.length) || '/'; 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 }); }, }); console.log(`[caldav] listening on 127.0.0.1:${port}; radicale on ${radicale.url}; storage ${paths.storagePath}`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'caldav', handles: ['caldav'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'caldav:server', port }); console.log(`[caldav] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[caldav] ${signal} received, shutting down...`); radicale.stop(); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));