calendar and contacts, extracted from the officer platform
one sidecar, one permission, one install — and two screens, because Calendar and Contacts are two places you go rather than two sections of one page. the first plugin to declare `screens`, which the platform gained for this. what moved: the radicale sidecar, the four routers, the panel apps, the app-password table and the settings form. the form became a modal on the calendar screen — a plugin ships panels and cannot contribute a settings section, which is the same wall the email plugin hit and the same answer. the routes are /calendar and /contacts, not /caldav/calendar. they were core routes and keeping them is not cosmetic: a bookmark, a dock tile someone arranged, and a persisted layout all address them. only a first-party plugin may choose its own segment; a third-party one is confined to /p/<publisher>/. four paths outside /api come with it, above the account gate, because phones are not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and have nowhere to put a JWT. that is not "open" — the sync door demands a scoped app password and resolves it to one user before radicale sees anything. two things deliberately unchanged. the table is still dav_app_passwords, not caldav_app_passwords: installs hold real rows and a naming convention is not worth a data migration on a live machine. and the panel names still resolve through the platform's catalogs, so the six locales they had as core screens survive rather than dropping to english to make the refactor tidier. per-user was already true and is worth writing down because the config said otherwise: the sync door resolves an app password to an account, tells radicale that id is the principal, and owner_only does the rest. the comment claiming "the owner is the only principal that exists" had been stale since multi-user landed on 2026-08-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
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);
|
||||
}
|
||||
+202
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/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<Response> {
|
||||
const url = new URL(req.url);
|
||||
const target = `${radicale.url}${subpath}${url.search}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
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
|
||||
// `<current-user-principal><href>/1/</href>`, 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()) {
|
||||
// `startupError` distinguishes "not installed" from "crashed and backing off". Both are 503, but
|
||||
// only one of them is fixed by waiting, and the logs alone did not say which.
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
running: false,
|
||||
error: radicale.startupError ?? 'radicale not running',
|
||||
installed: radicale.startupError === null,
|
||||
},
|
||||
{ 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<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 });
|
||||
},
|
||||
});
|
||||
|
||||
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<string, unknown>).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'));
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveRadicale } from './radicale';
|
||||
|
||||
// The failure this covers took a whole sidecar down on a fresh install: `spawn('radicale')` resolves
|
||||
// against the PATH the process STARTED with, which under PM2 has no `~/.local/bin`, and the ENOENT threw
|
||||
// at module load rather than being handled.
|
||||
|
||||
describe('resolving radicale', () => {
|
||||
test('finds the apt-installed binary', () => {
|
||||
const found = resolveRadicale('/usr/bin:/bin');
|
||||
// Skip rather than fail where radicale genuinely is not installed — this asserts the RESOLVER, and a
|
||||
// machine without the package would otherwise report a bug that is not there.
|
||||
if (!found) return;
|
||||
expect(found.command).toContain('radicale');
|
||||
expect(found.args).toEqual([]);
|
||||
});
|
||||
|
||||
test('falls back to the python module when no wrapper script is on PATH', () => {
|
||||
// A `pip install --user` into an environment without ~/.local/bin, or a distro shipping the module
|
||||
// with no script, both leave radicale importable and unfindable. The module is what matters.
|
||||
const pythonOnly = '/usr/bin';
|
||||
const found = resolveRadicale(pythonOnly);
|
||||
if (!found) return;
|
||||
// Either form is correct here; what must never happen is null while the module is importable.
|
||||
expect(found.command.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('returns null rather than throwing when nothing is installed', () => {
|
||||
// The whole point: a missing dependency must be a value the caller can handle, not an exception at
|
||||
// module load that crash-loops the sidecar and takes /_health down with it.
|
||||
expect(() => resolveRadicale('/nonexistent-dir')).not.toThrow();
|
||||
expect(resolveRadicale('/nonexistent-dir')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Supervises Radicale, the CalDAV/CardDAV server this sidecar fronts.
|
||||
//
|
||||
// WHY A REAL SERVER AND NOT OUR OWN. See docs/nextcloud-replacement.md. The short version: iCalendar and
|
||||
// vCard are easy, but `sync-collection` REPORT, RRULE expansion, VTIMEZONE and ctag/etag semantics are
|
||||
// not — and when they are subtly wrong a phone does not error, it silently stops syncing or silently
|
||||
// duplicates every event. Radicale has been getting that right since 2008.
|
||||
//
|
||||
// AUTH. Radicale runs with `type = http_x_remote_user`, meaning it trusts an `X-Remote-User` header
|
||||
// completely and does no authentication of its own. That is safe here for exactly the reason every
|
||||
// other sidecar's `X-Officer-User` is safe: it binds LOOPBACK ONLY, so nothing but this sidecar can
|
||||
// reach it, and this sidecar is only reachable through the platform, which is the thing that
|
||||
// authenticates. If Radicale is ever bound to a non-loopback interface this becomes an open door —
|
||||
// hence the explicit hostname below rather than a default.
|
||||
|
||||
export type Launcher = { command: string; args: string[] };
|
||||
|
||||
export type RadicaleHandle = {
|
||||
port: number;
|
||||
url: string;
|
||||
stop: () => void;
|
||||
isRunning: () => boolean;
|
||||
/** Why it never started, when it never started. Null once it has launched at least once. */
|
||||
startupError: string | null;
|
||||
};
|
||||
|
||||
// ── Finding radicale ──
|
||||
//
|
||||
// `spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and under PM2 that is a
|
||||
// login-less minimum. The same trap has now bitten `tea`, the plugin dependency probe and this — so the
|
||||
// search path is explicit rather than inherited.
|
||||
//
|
||||
// `/usr/bin` is listed even though it is almost always in PATH already: this is the apt package's
|
||||
// location, it is the supported install, and a PATH mangled by a shell profile should not be able to
|
||||
// break the normal case.
|
||||
const searchPath = (): string =>
|
||||
[join(homedir(), '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH ?? '']
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
/**
|
||||
* How to start radicale here, or null if it is not installed at all.
|
||||
*
|
||||
* Falls back to `python3 -m radicale` because radicale is a python package: a `pip install --user` in an
|
||||
* environment without `~/.local/bin` on PATH, or a distribution that ships the module without a wrapper
|
||||
* script, both leave it importable and unfindable. The module is what actually matters.
|
||||
*/
|
||||
export function resolveRadicale(pathOverride?: string): Launcher | null {
|
||||
const PATH = pathOverride ?? searchPath();
|
||||
|
||||
const direct = Bun.which('radicale', { PATH });
|
||||
if (direct) return { command: direct, args: [] };
|
||||
|
||||
const python = Bun.which('python3', { PATH });
|
||||
if (python) {
|
||||
const probe = spawnSync(python, ['-c', 'import radicale'], { stdio: 'ignore', env: { PATH } });
|
||||
if (probe.status === 0) return { command: python, args: ['-m', 'radicale'] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type StartParams = {
|
||||
/** Where collections live. Created if absent. */
|
||||
storagePath: string;
|
||||
/** Where the generated config is written. Never hand-edit it; it is rewritten on every boot. */
|
||||
configPath: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
// Radicale's own config format. Regenerated at every boot on purpose: the config is derived state, and
|
||||
// a hand-edit that silently disagreed with what this file thinks is true would be very hard to debug.
|
||||
const configFor = (storagePath: string, port: number) => `# GENERATED by officer-caldav on every boot.
|
||||
# Hand edits are lost. See src/servers/sidecar/caldav/radicale.ts.
|
||||
[server]
|
||||
hosts = 127.0.0.1:${port}
|
||||
# The platform terminates TLS; this hop is loopback.
|
||||
ssl = False
|
||||
|
||||
[auth]
|
||||
# Trusts X-Remote-User outright. Safe ONLY because of the loopback bind above — see the note in
|
||||
# radicale.ts before changing either.
|
||||
type = http_x_remote_user
|
||||
|
||||
[storage]
|
||||
type = multifilesystem
|
||||
filesystem_folder = ${storagePath}
|
||||
|
||||
[rights]
|
||||
# The owner is the only principal that exists; single-user is a platform invariant.
|
||||
type = owner_only
|
||||
|
||||
[logging]
|
||||
level = warning
|
||||
`;
|
||||
|
||||
export function startRadicale({ storagePath, configPath, port }: StartParams): RadicaleHandle {
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
writeFileSync(configPath, configFor(storagePath, port));
|
||||
|
||||
let child: ChildProcess | null = null;
|
||||
let stopped = false;
|
||||
let restarts = 0;
|
||||
|
||||
// Resolved ONCE, before anything can throw. This used to be a bare `spawn('radicale')` at module top
|
||||
// level, so on a machine without it the ENOENT killed the sidecar and PM2 crash-looped it — which is
|
||||
// the opposite of what the backoff below is for, and it took `/_health` down with it. A missing
|
||||
// dependency should be a sidecar that answers "radicale is not installed", not a restart loop whose
|
||||
// logs say ENOENT and nothing about the fix.
|
||||
const launcher = resolveRadicale();
|
||||
|
||||
if (!launcher) {
|
||||
const message =
|
||||
'radicale is not installed. It ships with machine-setup; install it with ' +
|
||||
'`sudo apt install radicale` (Debian/Ubuntu), `brew install radicale` (macOS), ' +
|
||||
'or `pip install --user radicale`. Calendar and contacts are unavailable until then.';
|
||||
console.error(`[caldav] ${message}`);
|
||||
return {
|
||||
port,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
isRunning: () => false,
|
||||
startupError: message,
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const launch = () => {
|
||||
if (stopped) return;
|
||||
child = spawn(launcher.command, [...launcher.args, '--config', configPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Radicale must not inherit the platform's environment wholesale — Bun auto-loads .env into every
|
||||
// process started in the platform directory, and there is no reason for a calendar server to hold
|
||||
// an Anthropic key or a database URL.
|
||||
// The augmented PATH, not the inherited one — `python3 -m radicale` needs to find its own
|
||||
// interpreter's neighbours, and a wrapper script may shell out.
|
||||
env: { PATH: searchPath(), HOME: process.env.HOME ?? '' },
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (buf: Buffer) => process.stdout.write(`[radicale] ${buf}`));
|
||||
child.stderr?.on('data', (buf: Buffer) => process.stderr.write(`[radicale] ${buf}`));
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
child = null;
|
||||
if (stopped) return;
|
||||
// Backoff, capped. A crash loop should be visible in the logs rather than a busy spin, and the
|
||||
// sidecar itself stays up so /_health can keep reporting the truth.
|
||||
restarts += 1;
|
||||
const delay = Math.min(30_000, 500 * 2 ** Math.min(restarts, 6));
|
||||
console.error(`[caldav] radicale exited (code=${code} signal=${signal}); restarting in ${delay}ms`);
|
||||
setTimeout(launch, delay);
|
||||
});
|
||||
};
|
||||
|
||||
launch();
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
isRunning: () => child !== null,
|
||||
startupError: null,
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
child?.kill('SIGTERM');
|
||||
child = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the paths this sidecar owns under DATA_PATH. The platform never opens any of them. */
|
||||
export function davPaths(dataPath: string) {
|
||||
const root = join(dataPath, 'dav');
|
||||
return { root, storagePath: join(root, 'collections'), configPath: join(root, 'radicale.conf') };
|
||||
}
|
||||
Reference in New Issue
Block a user