import { createRouter } from '../../create-router'; import { getUserByEmail, verifyDavAppPassword } from 'officerdb'; import { getCaldavUrl } from './sidecar-server'; // The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter. // // WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird // speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer, // cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same // situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform // session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type. // // The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else. // An app password is NOT a session: userMiddleware must never learn to accept one, or a device that // syncs a calendar would also reach the wallet. // // Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform // authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the // same one create-proxy.ts documents, and for the same reason. export const davSyncRouter = createRouter(); const unauthorized = () => new Response('Unauthorized', { status: 401, // Without this header a DAV client will not prompt for credentials at all — it just fails. The // realm string is what the phone shows in its password dialog. headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' }, }); type BasicCreds = { username: string; password: string }; function parseBasic(header: string | undefined): BasicCreds | null { if (!header?.startsWith('Basic ')) return null; let decoded: string; try { decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); } catch { return null; } // Split on the FIRST colon only — a generated password never contains one, but an email local-part // legally can, and splitting greedily would break those accounts in a way that looks like a wrong // password. const at = decoded.indexOf(':'); if (at < 0) return null; return { username: decoded.slice(0, at), password: decoded.slice(at + 1) }; } // Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a // collection, its children or the whole tree, so dropping it makes every calendar look empty. const REQUEST_HEADERS = [ 'depth', 'content-type', 'content-length', 'if', 'if-match', 'if-none-match', 'destination', 'overwrite', 'lock-token', 'timeout', 'prefer', 'user-agent', ] as const; const RESPONSE_HEADERS = [ 'content-type', 'dav', 'allow', 'etag', 'last-modified', 'location', 'lock-token', 'preference-applied', 'vary', ] as const; davSyncRouter.all('/*', async (ctx) => { const creds = parseBasic(ctx.req.header('authorization')); if (!creds) return unauthorized(); // A credential-less request is the normal opening move — the client is asking to be challenged — so it // is not worth a line. A credential that is present and wrong is the interesting case, and it is // indistinguishable from the normal one at the client end: both just say "password incorrect". const user = await getUserByEmail(creds.username); if (!user) { console.warn(`[dav] auth failed: no such user ${creds.username}`); return unauthorized(); } const userId = await verifyDavAppPassword(user.id, creds.password); if (!userId) { console.warn(`[dav] auth failed: bad app password for ${creds.username}`); return unauthorized(); } const base = getCaldavUrl(); if (!base) return new Response('caldav sidecar not available', { status: 503 }); const url = new URL(ctx.req.url); // The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale // generates absolute hrefs in its multistatus responses, and if the path the client sees differs // from the path the server thinks it is at, every href points somewhere that 404s. const target = `${base}${url.pathname}${url.search}`; const headers: Record = {}; for (const name of REQUEST_HEADERS) { const value = ctx.req.header(name); if (value) headers[name] = value; } headers['X-Officer-User'] = String(userId); const method = ctx.req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; let upstream: Response; try { upstream = await fetch(target, { method, headers, body: hasBody ? await ctx.req.arrayBuffer() : undefined, }); } catch (err) { console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) }); return new Response('caldav sidecar unreachable', { status: 502 }); } const out = new Headers(); for (const name of RESPONSE_HEADERS) { const value = upstream.headers.get(name); if (value) out.set(name, value); } // One line per request, with the client's own name for itself. A DAV client tells you almost nothing // when it fails — iOS in particular reports every setup failure as "Cannot connect using SSL" — so the // only way to know whether a phone ever asked for an address book is to record that it did. Cheap: // a sync is a handful of requests, and a body is never logged. console.log( `[dav] ${method} ${url.pathname} -> ${upstream.status} (user=${userId}, ua=${(ctx.req.header('user-agent') ?? '-').slice(0, 60)})`, ); return new Response(upstream.body, { status: upstream.status, headers: out }); });