a DAV client tells you almost nothing when it fails — iOS reports every setup failure as "Cannot connect using SSL" regardless of cause — so the only way to know whether a phone ever asked for an address book is to record that it did. one line per proxied request with the client's own user-agent, and a warning when a credential is present but wrong. a missing credential is the normal opening move and stays unlogged; a wrong one is indistinguishable from it at the client end, where both just say "password incorrect". no body is ever logged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
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<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);
|
|
}
|
|
|
|
// 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 });
|
|
});
|