caldav/carddav: officer-caldav sidecar and the /dav door
first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.
radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.
two doors, because a browser should not speak dav:
/dav/* top-level, http basic against a scoped app password, every
verb and every dav header forwarded verbatim. this is what
davx5 and ios talk to. same reasoning as /api/vault being
mounted outside protectedRouter.
/api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.
the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.
new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.
.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.
verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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();
|
||||
|
||||
const user = await getUserByEmail(creds.username);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
const userId = await verifyDavAppPassword(user.id, creds.password);
|
||||
if (!userId) 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<string, string> = {};
|
||||
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);
|
||||
}
|
||||
return new Response(upstream.body, { status: upstream.status, headers: out });
|
||||
});
|
||||
Reference in New Issue
Block a user