caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own:6b5c87daa plugin can be more than one screen;e0df28ffa first-party plugin can claim a reserved root path. this commit adds the third, `personal` on a plugin permission — a member granted READ must still mint and revoke their own app password, because that password is how their own phone reaches their own calendar. those rows are theirs and nobody else's. two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route. /calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh dock that silently dropped it would read as a downgrade. docs:check earned its keep twice here: it caught spotlight still offering /settings/integrations/dav-app-passwords after the section was deleted, and the permission count going 22 -> 21. neither is greppable and both were already wrong when it ran. verified against alpha, which is still on the pre-extraction code: /.well-known/ caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than being answered as a CORS preflight. installed here end to end — schema applied, sidecar started, two tiles with their own artwork behind one permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,189 +0,0 @@
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
|
||||
// Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it
|
||||
// against a single-use token until Safari fetches it.
|
||||
//
|
||||
// WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app
|
||||
// hands the URL to the OS browser and loses all control of the request. So the token IS the credential.
|
||||
// It is therefore minted long, used once, and dropped after five minutes, because the thing it protects
|
||||
// is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a
|
||||
// password that stays valid.
|
||||
//
|
||||
// WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the
|
||||
// install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the
|
||||
// profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute
|
||||
// window costs the owner one extra tap; persisting the secret costs the guarantee.
|
||||
//
|
||||
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
|
||||
|
||||
const TTL_MS = 5 * 60_000;
|
||||
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
|
||||
const MAX_PENDING = 32;
|
||||
|
||||
type PendingProfile = { body: Uint8Array; expiresAt: number };
|
||||
|
||||
const pending = new Map<string, PendingProfile>();
|
||||
|
||||
const sweep = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token);
|
||||
};
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
type ProfileParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
/** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
|
||||
principalUrl: string;
|
||||
/** What the user sees as the account name in Settings. */
|
||||
accountName: string;
|
||||
};
|
||||
|
||||
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
|
||||
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
|
||||
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
|
||||
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
|
||||
function buildPlist(params: ProfileParams): string {
|
||||
const { host, port, username, password, principalUrl, accountName } = params;
|
||||
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
|
||||
// two Officer instances on one phone do not overwrite each other.
|
||||
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||
const e = escapeXml;
|
||||
|
||||
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
|
||||
<key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
|
||||
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
|
||||
<key>PayloadDisplayName</key><string>${e(label)}</string>
|
||||
<key>${kind}AccountDescription</key><string>${e(accountName)}</string>
|
||||
<key>${kind}HostName</key><string>${e(host)}</string>
|
||||
<key>${kind}Port</key><integer>${port}</integer>
|
||||
<key>${kind}UseSSL</key><true/>
|
||||
<key>${kind}Username</key><string>${e(username)}</string>
|
||||
<key>${kind}Password</key><string>${e(password)}</string>
|
||||
<key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
|
||||
</dict>`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>${identifier}</string>
|
||||
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
|
||||
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar & Contacts</string>
|
||||
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
|
||||
<key>PayloadOrganization</key><string>Officer</string>
|
||||
<key>PayloadScope</key><string>User</string>
|
||||
<key>PayloadRemovalDisallowed</key><false/>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
${account('CalDAV', 'Officer Calendar')}
|
||||
${account('CardDAV', 'Officer Contacts')}
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
// Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS
|
||||
// terminates upstream. An unsigned profile installs exactly the same way; the only difference is that
|
||||
// the install screen says "Not Signed" in red instead of naming the signer.
|
||||
//
|
||||
// Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy
|
||||
// works, iOS checks the trust chain and not the key usage) and profiles are signed from then on:
|
||||
// DAV_PROFILE_SIGN_CERT leaf certificate, PEM
|
||||
// DAV_PROFILE_SIGN_KEY its private key, PEM
|
||||
// DAV_PROFILE_SIGN_CHAIN intermediates, PEM (iOS ships roots, not intermediates — without this the
|
||||
// signature cannot be verified and shows as Not Verified)
|
||||
//
|
||||
// Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up
|
||||
// with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here.
|
||||
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
|
||||
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
|
||||
// old one is removed. Nothing detects that for you.
|
||||
const signingConfig = () => {
|
||||
const cert = process.env.DAV_PROFILE_SIGN_CERT;
|
||||
const key = process.env.DAV_PROFILE_SIGN_KEY;
|
||||
if (!cert || !key) return null;
|
||||
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
|
||||
};
|
||||
|
||||
let warnedUnsigned = false;
|
||||
|
||||
async function sign(plist: string): Promise<Uint8Array | null> {
|
||||
const config = signingConfig();
|
||||
if (!config) {
|
||||
if (!warnedUnsigned) {
|
||||
warnedUnsigned = true;
|
||||
console.warn(
|
||||
'[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and
|
||||
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
|
||||
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
|
||||
// the file is a signature with no profile embedded in it.
|
||||
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
|
||||
if (config.chain) args.push('-certfile', config.chain);
|
||||
args.push('-md', 'sha256');
|
||||
|
||||
const proc = Bun.spawn(['openssl', ...args], {
|
||||
stdin: new TextEncoder().encode(plist),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err, code] = await Promise.all([
|
||||
new Response(proc.stdout).arrayBuffer(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (code !== 0 || out.byteLength === 0) {
|
||||
// Deliberately not fatal. An unsigned profile still installs and still works; failing the whole
|
||||
// provisioning request over a cosmetic signature would be the worse outcome.
|
||||
console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`);
|
||||
return null;
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
|
||||
|
||||
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
|
||||
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
|
||||
sweep();
|
||||
if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles');
|
||||
|
||||
const plist = buildPlist(params);
|
||||
const signed = await sign(plist);
|
||||
const body = signed ?? new TextEncoder().encode(plist);
|
||||
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const expiresAt = Date.now() + TTL_MS;
|
||||
pending.set(token, { body, expiresAt });
|
||||
|
||||
return {
|
||||
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
signed: signed !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */
|
||||
export function claimIosProfile(token: string): Uint8Array | null {
|
||||
sweep();
|
||||
const entry = pending.get(token);
|
||||
if (!entry) return null;
|
||||
pending.delete(token);
|
||||
return entry.body;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { stashIosProfile } from './ios-profile';
|
||||
|
||||
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
|
||||
// under /api — this is the owner administering their devices from a logged-in browser, which is a
|
||||
// completely different act from a phone syncing (that is /dav, see sync-router.ts).
|
||||
//
|
||||
// The plaintext credential exists for exactly one response and is never stored, so POST is the only
|
||||
// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the
|
||||
// row and mint another. That is cheaper than any design where the secret can be read back.
|
||||
|
||||
export const davRouter = createRouter();
|
||||
|
||||
davRouter.get('/passwords', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
||||
});
|
||||
|
||||
davRouter.post('/passwords', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { label?: string } | undefined;
|
||||
const label = body?.label?.trim();
|
||||
if (!label) throw errors.BAD_REQUEST('label is required');
|
||||
|
||||
const { entry, password } = await createDavAppPassword(user.id, label);
|
||||
// `password` is returned once, here. Nothing else in the system can produce it again.
|
||||
return ctx.json({ entry, password, username: user.email });
|
||||
});
|
||||
|
||||
// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app
|
||||
// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is
|
||||
// held in memory behind a one-shot token rather than stored.
|
||||
//
|
||||
// This is the only endpoint that returns a route to a live password instead of the password itself. It
|
||||
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
||||
davRouter.post('/provision/ios', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { deviceLabel?: string } | undefined;
|
||||
const deviceLabel = body?.deviceLabel?.trim();
|
||||
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
||||
|
||||
// The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can
|
||||
// actually reach — the request's own Host header is whatever the reverse proxy forwarded.
|
||||
const publicUrl = process.env.OFFICERDEV_PUBLIC_URL ?? process.env.PUBLIC_URL;
|
||||
if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile');
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(publicUrl);
|
||||
} catch {
|
||||
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL');
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
// CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that
|
||||
// cannot work. Better to say so than to ship a phone an account that silently never syncs.
|
||||
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device');
|
||||
}
|
||||
|
||||
const { entry, password } = await createDavAppPassword(user.id, deviceLabel);
|
||||
|
||||
const stored = await stashIosProfile(
|
||||
{
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 443,
|
||||
username: user.email,
|
||||
password,
|
||||
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
|
||||
// collection the owner adds later appears without re-provisioning.
|
||||
principalUrl: `/dav/${user.id}/`,
|
||||
accountName: 'Officer',
|
||||
},
|
||||
url.origin,
|
||||
);
|
||||
|
||||
console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`);
|
||||
return ctx.json(stored);
|
||||
});
|
||||
|
||||
davRouter.post('/passwords/:id/revoke', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
||||
if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
davRouter.delete('/passwords/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
||||
if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// The officer-caldav sidecar has TWO consumers in the platform and they need different things, so the
|
||||
// port capture lives here and both import it.
|
||||
//
|
||||
// • `caldavRouter` — the ordinary JSON door for Officer's own UI. Behind userMiddleware, forwarded by
|
||||
// the shared factory with `X-Officer-User`, exactly like every other sidecar.
|
||||
// • `davSyncRouter` — the DAV door for phones. Cannot use the factory: it forwards only three headers
|
||||
// (`create-proxy.ts:88`) and DAV dies without `Depth`, and it derives the user from a platform JWT
|
||||
// that a DAV client has no way to hold.
|
||||
//
|
||||
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
|
||||
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
|
||||
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
|
||||
|
||||
export const caldavRouter = proxy.router;
|
||||
export const getCaldavUrl = proxy.getHttpUrl;
|
||||
@@ -1,143 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user