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
252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
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'));
|