calendar and contacts, extracted from the officer platform

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
This commit is contained in:
2026-08-27 04:53:45 +00:00
co-authored by Claude Opus 5
commit dcbc9b102c
30 changed files with 2362 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+44
View File
@@ -0,0 +1,44 @@
# Calendar
Calendars and contacts on your own machine, synced to your phone over CalDAV and CardDAV.
- **Radicale, not a container.** A ~2MB Python package, spawned on loopback by this plugin's sidecar. It
was a Docker service only because the old app store made everything one.
- **Per user.** The sync door resolves an app password to one account and tells Radicale that account's id
is the principal; `[rights] type = owner_only` does the rest. Every user gets their own calendars and
their own contacts, in their own directory, invisible to everyone else.
- **Phones work.** DAVx5, iOS and Thunderbird, including RFC 6764 autodiscovery from a bare domain.
## Two screens, one plugin
`/calendar` and `/contacts`. They are two places you go rather than two sections of one page, which is why
this is the first plugin to declare `screens` in its manifest — one sidecar, one permission, one install,
two routes and two dock tiles.
## What it claims from the platform, and why
Four paths outside `/api`, above the account gate:
| Path | Why it cannot sit behind the session |
| --- | --- |
| `/dav/*` | DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a JWT |
| `/dav/provision/*.mobileconfig` | Safari is handed a URL by the phone app and has no credential; the one-shot token in the path IS the authentication |
| `/.well-known/caldav` | RFC 6764 — probed with a bare domain before the client has any credential |
| `/.well-known/carddav` | the same, for contacts |
"Above the account gate" is not "open". The sync door demands a scoped DAV app password and resolves it to
a user before anything reaches Radicale.
Every one of those is in the platform's `RESERVED_ROOT`, claimable **only** by a first-party plugin. A
third-party plugin is confined to `/p/<publisher>/` so it can never shadow a core route.
## Extracted from the platform on 2026-08-27
It was core from 2026-08-19. What moved: the sidecar, the four routers, the panel apps, the app-password
table, and the settings form — which became a modal on the Calendar screen, because a plugin ships panels
and cannot contribute a settings section.
Two things deliberately did **not** change. The table is still `dav_app_passwords` rather than
`caldav_app_passwords`: installs already hold real rows, and the platform's prefix convention is not worth
a data migration on a live machine. And the panel names still resolve through the platform's translation
catalogs, so the six locales they had as core screens survive.
+100
View File
@@ -0,0 +1,100 @@
import { createRouter } from '@@/create-router';
import {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
} from '../db/queries';
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 appPasswordsRouter = createRouter();
appPasswordsRouter.get('/passwords', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
});
appPasswordsRouter.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.
appPasswordsRouter.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);
});
appPasswordsRouter.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 });
});
appPasswordsRouter.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 });
});
+189
View File
@@ -0,0 +1,189 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 &amp; 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;
}
+82
View File
@@ -0,0 +1,82 @@
import { createRouter } from '@@/create-router';
import { caldavRouter } from './sidecar-server';
import { appPasswordsRouter } from './app-passwords';
import { davSyncRouter } from './sync-router';
import { claimIosProfile } from './ios-profile';
/**
* The plugin's two doors, and why it needs two.
*
* ── `router` — the ordinary one ──
*
* Mounted at `/api/caldav`, behind the account gate and the permission gate like every other plugin. It
* carries the JSON door the Calendar and Contacts screens read (`/_officer/*`, proxied to the sidecar)
* and the app-password management the modal uses.
*
* `/passwords` used to be `/api/dav/passwords`, a second top-level API prefix the platform granted this
* one feature. A plugin gets one prefix, which is the rule that stops a plugin spreading across the API
* surface — so it moved under this one. The UI moved with it.
*
* ── `publicRouter` — the one that exists because phones are not browsers ──
*
* Mounted at the top-level paths claimed in the manifest, ABOVE the account gate. That is not a weakening:
* DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a platform JWT, so
* `userMiddleware` would 401 every real client. `sync-router` demands its own credential — a scoped DAV
* app password, resolved to a user, and Radicale is then told which principal that is.
*/
export const router = createRouter();
// The JSON door for this plugin's own screens. `_officer/*` is the sidecar's namespace; everything under
// it is proxied to the Radicale-holding sidecar, which is the only thing that ever sees a DAV credential.
router.route('/', caldavRouter);
router.route('/', appPasswordsRouter);
/**
* Mounted at EVERY path the manifest claims — `/dav`, `/.well-known/caldav`, `/.well-known/carddav` — so
* it has to work out which one it is answering rather than assume.
*
* The platform mounts one router per claim and hono strips the prefix, so a request to
* `/.well-known/caldav` arrives here as `/`, indistinguishable from `/dav/` unless the original path is
* consulted. `ctx.req.path` is the whole path, before stripping.
*/
export const publicRouter = createRouter();
/*
Autodiscovery. Most of what makes adding an account on a phone feel transparent instead of fiddly: the
client is given a bare domain and probes these UNAUTHENTICATED, before it has any credential.
`.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
client while looking perfectly healthy in a browser.
*/
publicRouter.all('*', async (ctx, next) => {
if (!ctx.req.path.startsWith('/.well-known/')) return next();
return ctx.redirect('/dav/', 301);
});
/*
The iOS profile download, BEFORE the sync mount below because hono matches in registration order and
`davSyncRouter`'s `/*` would otherwise demand HTTP Basic for it. Safari has no credential to offer — it
was handed a URL by the app and nothing else — so the one-shot token in the path IS the authentication.
Minted by `POST /api/caldav/provision/ios`.
*/
publicRouter.get('/provision/:file', (ctx) => {
const file = ctx.req.param('file');
const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null;
const body = token ? claimIosProfile(token) : null;
// Expired, already used, or never existed — all the same 404. There is nothing useful to tell a caller
// who has the wrong token, and distinguishing the cases would confirm that a token once existed.
if (!body) return ctx.text('not found', 404);
return new Response(body as unknown as BodyInit, {
headers: {
// Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or
// text/xml the file downloads and the OS does nothing with it.
'Content-Type': 'application/x-apple-aspen-config',
'Cache-Control': 'no-store',
},
});
});
publicRouter.route('/', davSyncRouter);
+17
View File
@@ -0,0 +1,17 @@
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;
+144
View File
@@ -0,0 +1,144 @@
import { createRouter } from '@@/create-router';
import { getUserByEmail } from 'officerdb';
import { verifyDavAppPassword } from '../db/queries';
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 });
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+9
View File
@@ -0,0 +1,9 @@
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries';
export type { DavAppPassword, DavAppPasswordView } from './queries';
+113
View File
@@ -0,0 +1,113 @@
import { and, desc, eq, isNull } from 'drizzle-orm';
import argon2 from 'argon2';
import { randomBytes } from 'node:crypto';
import { db } from 'officerdb';
import { davAppPasswords } from './schema';
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
/** What the UI is allowed to see: everything except the hash. */
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
const view = (row: DavAppPassword): DavAppPasswordView => {
const { passwordHash: _hash, ...rest } = row;
return rest;
};
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
// into a phone by hand. Grouped into blocks of four for the same reason.
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
function generateSecret(): string {
const bytes = randomBytes(20);
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
// still fits in five readable blocks.
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
}
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
const rows = await db
.select()
.from(davAppPasswords)
.where(eq(davAppPasswords.userId, userId))
.orderBy(desc(davAppPasswords.createdAt));
return rows.map(view);
}
/**
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
* value is the only chance the owner gets to see it.
*/
export async function createDavAppPassword(
userId: number,
label: string,
): Promise<{ entry: DavAppPasswordView; password: string }> {
const password = generateSecret();
const [row] = await db
.insert(davAppPasswords)
.values({
userId,
label,
passwordHash: await argon2.hash(password),
hint: password.slice(0, 8),
})
.returning();
if (!row) throw new Error('failed to create dav app password');
return { entry: view(row), password };
}
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.update(davAppPasswords)
.set({ revokedAt: new Date() })
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
.returning();
return !!row;
}
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.delete(davAppPasswords)
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
.returning();
return !!row;
}
/**
* Verify a Basic-auth credential, returning the owning user id.
*
* Every live credential is tried, because the username a DAV client sends is the account email, not a
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
* UI should encourage deleting devices that are gone rather than accumulating them.
*
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
* the column exists to answer "is this device still around", not to be an access log.
*/
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
const rows = await db
.select()
.from(davAppPasswords)
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
for (const row of rows) {
let ok = false;
try {
ok = await argon2.verify(row.passwordHash, password);
} catch {
ok = false; // a corrupt hash must not take the whole login path down
}
if (!ok) continue;
const now = Date.now();
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
await db
.update(davAppPasswords)
.set({ lastUsedAt: new Date(now) })
.where(eq(davAppPasswords.id, row.id));
}
return row.userId;
}
return null;
}
+46
View File
@@ -0,0 +1,46 @@
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from 'officerdb/auth/schema';
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
//
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
// credential a phone could carry is the account password itself — which then lives in that phone's
// account manager in recoverable form, and in whatever backs the phone up.
//
// So: one password per device, revocable per device, hashed with argon2 like the account password and
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
//
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
// this table — an app password is not a session, and a device that syncs a calendar has no business
// reaching the wallet or the vault.
//
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
// noticeable at all.
export const davAppPasswords = pgTable(
'dav_app_passwords',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
label: text('label').notNull(),
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
passwordHash: text('password_hash').notNull(),
/**
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
* authenticate with, and it is the only way for the owner to match a row in this list against the
* password saved on a device they are looking at.
*/
hint: text('hint').notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
// being free and it being a sequential scan on every PROPFIND.
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
);
+78
View File
@@ -0,0 +1,78 @@
import type { PluginManifest } from '@@/plugins/manifest';
/**
* Calendar and contacts, over CalDAV and CardDAV.
*
* One sidecar (Radicale on loopback), one permission, one install — and TWO screens, because Calendar and
* Contacts are two places you go rather than two sections of one page. That is what `screens` below is
* for, and caldav is the first plugin to use it.
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Calendar',
summary: 'Calendars and contacts on your own machine, synced to your phone over CalDAV and CardDAV.',
color: '#ef4444',
/**
* Two screens at URLs this plugin chose — `/calendar` and `/contacts`, not `/caldav/calendar`.
*
* They were core routes until 2026-08-27 and keeping them is not cosmetic: a bookmark, a dock tile a
* user has arranged, and the layout persisted under `screens/plugin/caldav/...` all address them.
*
* Only a first-party plugin may choose its own segment — a third-party one is confined to
* `/p/<publisher>/` so it cannot shadow a core route. Each ships its own artwork as
* `assets/<segment>.png`, because two identical tiles side by side say nothing about which is which.
*/
screens: [
{ segment: 'calendar', label: 'Calendar', icon: 'CalendarDays' },
{ segment: 'contacts', label: 'Contacts', icon: 'Contact' },
],
permissions: [
{
key: 'calendar',
label: 'Calendar and contacts',
description: 'Calendars, contacts, and the app passwords that sync them to a phone',
// App passwords are minted for and revoked by their own owner, and are how a member's own phone
// reaches their own calendar. Nothing under here is shared, so a read grant may still write them.
personal: ['/passwords', '/provision'],
},
],
/**
* The top-level doors, above the account gate — and the reason is that phones are not browsers.
*
* DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a platform JWT,
* so the account gate would 401 every real client. `sync-router` demands its own credential instead: a
* scoped DAV app password, resolved to one user, whose id is then what Radicale is told the principal
* is. "Unauthenticated" here means "not authenticated by the PLATFORM's session", not "open".
*
* The two `.well-known` names are RFC 6764 autodiscovery: the client is given a bare domain and probes
* them before it has any credential at all. Without them iOS degrades to demanding a full collection
* URL, which is exactly the sort of thing that makes self-hosting feel worse than the product it
* replaces.
*
* Every one of these was the platform's until this plugin existed, and each is in `RESERVED_ROOT` for
* that reason — claimable by a first-party plugin, refused to everyone else.
*/
unauthenticatedRootPaths: ['/dav', '/.well-known/caldav', '/.well-known/carddav'],
/**
* Radicale — a ~2MB Python package, spawned on loopback by this plugin's sidecar.
*
* Not a container: it was one only because the old app store made everything one. No Homebrew formula
* exists (confirmed live: "No available formula with the name radicale"), so a macOS dev install has
* nowhere for the sidecar to find it — which is why `resolveRadicale()` failing is handled rather than
* fatal.
*/
osDependencies: [
{
binary: 'radicale',
reason: 'Serves the CalDAV and CardDAV collections. Without it there are no calendars and no contacts.',
packages: { apt: 'radicale', pacman: 'radicale', dnf: 'radicale' },
},
],
};
+130
View File
@@ -0,0 +1,130 @@
import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical';
// The JSON door: what Officer's own web UI reads.
//
// This talks DAV to Radicale over loopback rather than reading Radicale's on-disk format. The storage
// layout is Radicale's private business and changes between versions; PROPFIND is its supported
// interface and costs one in-process HTTP hop. Parsing the storage directly would be faster and would
// break silently on an upgrade, which is a bad trade for a personal calendar.
//
// The browser never sees any of this XML — that is the entire point of having a JSON door. See
// docs/nextcloud-replacement.md, "two front doors, not one".
export type Collection = {
/** DAV path, relative to the /dav mount — the id the UI passes back. */
path: string;
displayName: string;
kind: 'calendar' | 'addressbook';
/** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */
color?: string;
};
type Fetcher = (path: string, init: RequestInit) => Promise<Response>;
// Deliberately not a real XML parser. The only documents read here are Radicale's own multistatus
// responses — a narrow, machine-generated shape, not arbitrary user XML — and adding a parser
// dependency to pull four fields out of it is not worth the weight. If this ever needs to read XML
// from somewhere less predictable, it should grow a parser rather than more regexes.
const between = (xml: string, tag: string): string[] => {
const out: string[] = [];
const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tag}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${tag}>`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) out.push(m[1] ?? '');
return out;
};
const first = (xml: string, tag: string): string | undefined => between(xml, tag)[0];
const decodeEntities = (s: string) =>
s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
const PROPFIND_COLLECTIONS = `<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:IC="http://apple.com/ns/ical/">
<D:prop><D:resourcetype/><D:displayname/><IC:calendar-color/></D:prop>
</D:propfind>`;
/** Every calendar and address book belonging to this user. */
export async function listCollections(fetcher: Fetcher, userId: string): Promise<Collection[]> {
const res = await fetcher(`/${userId}/`, {
method: 'PROPFIND',
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: PROPFIND_COLLECTIONS,
});
if (!res.ok && res.status !== 207) return [];
const xml = await res.text();
const collections: Collection[] = [];
for (const response of between(xml, 'response')) {
const href = first(response, 'href');
if (!href) continue;
// The tag must END here — `\b` alone also matches `<C:calendar-home-set/>`, which the principal
// carries, and that made the principal itself show up in the list as a calendar named "1".
const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\s*\/>/.test(response);
const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\s*\/>/.test(response);
if (!isCalendar && !isAddressbook) continue; // the principal itself, or a plain collection
const path = decodeEntities(href.trim());
const displayName = decodeEntities((first(response, 'displayname') ?? '').trim());
const color = (first(response, 'calendar-color') ?? '').trim() || undefined;
const lastSegment = path.replace(/\/$/, '').split('/').pop() || 'Untitled';
collections.push({
path,
// Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a
// name. A real client sets something human when it creates a collection; until one does, the last
// segment is the better label — and it is what every DAV client falls back to anyway.
displayName: displayName && !displayName.includes('/') ? displayName : lastSegment,
kind: isCalendar ? 'calendar' : 'addressbook',
color,
});
}
return collections;
}
/**
* Every object in one collection, in one round trip.
*
* REPORT, not PROPFIND. `calendar-data` and `address-data` are not WebDAV live properties — RFC 4791 and
* RFC 6352 define them as report-only, and Radicale correctly returns an empty prop for them under
* PROPFIND. Asking the wrong way produces a 207 full of nothing, which reads exactly like "the calendar
* is empty" and is a genuinely confusing way to be wrong.
*
* One report rather than a listing plus a GET per item: a calendar with a thousand events would
* otherwise be a thousand round trips.
*/
async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'addressbook'): Promise<string[]> {
const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data';
const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav';
const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query';
// An empty filter means "everything". A calendar-query with no comp-filter is legal and is the
// cheapest way to say it.
const filter = kind === 'calendar' ? '<X:filter><X:comp-filter name="VCALENDAR"/></X:filter>' : '<X:filter/>';
const res = await fetcher(path, {
method: 'REPORT',
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: `<?xml version="1.0"?><X:${queryTag} xmlns:D="DAV:" xmlns:X="${ns}"><D:prop><X:${dataTag}/></D:prop>${filter}</X:${queryTag}>`,
});
if (!res.ok && res.status !== 207) return [];
const xml = await res.text();
return between(xml, dataTag)
.map((chunk) => decodeEntities(chunk).trim())
.filter(Boolean);
}
export async function listEvents(fetcher: Fetcher, path: string): Promise<CalendarEvent[]> {
const blobs = await readObjects(fetcher, path, 'calendar');
return blobs.flatMap(parseEvents);
}
export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
const blobs = await readObjects(fetcher, path, 'addressbook');
return blobs.flatMap(parseContacts);
}
+202
View File
@@ -0,0 +1,202 @@
// Just enough iCalendar/vCard to drive a list view.
//
// SCOPE, DELIBERATELY SMALL. This unfolds lines, splits properties and pulls out the handful of fields a
// UI shows. It does NOT expand RRULE, and it does not resolve VTIMEZONE — see
// docs/nextcloud-replacement.md for why those are the expensive half and why Radicale, not this file,
// owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us
// pretending to know when.
//
// Everything here is display-only. The authoritative copy of an event is the .ics on disk that DAV
// clients read and write; nothing in this file ever writes one back.
/** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */
function unfold(text: string): string[] {
const out: string[] = [];
for (const raw of text.split(/\r?\n/)) {
if ((raw.startsWith(' ') || raw.startsWith('\t')) && out.length > 0) {
out[out.length - 1] += raw.slice(1);
} else if (raw.length > 0) {
out.push(raw);
}
}
return out;
}
export type Prop = { name: string; params: Record<string, string>; value: string };
/** `DTSTART;TZID=Europe/Lisbon:20260805T090000` → name, params, value. */
function parseLine(line: string): Prop | null {
const colon = line.indexOf(':');
if (colon < 0) return null;
const head = line.slice(0, colon);
const value = line.slice(colon + 1);
const [name, ...paramParts] = head.split(';');
const params: Record<string, string> = {};
for (const part of paramParts) {
const eq = part.indexOf('=');
if (eq > 0) params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1).replace(/^"|"$/g, '');
}
return { name: (name ?? '').toUpperCase(), params, value };
}
// Escaped per RFC 5545 §3.3.11. Order matters: backslash last, or it would un-escape the escapes.
const unescapeText = (value: string) =>
value.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
/**
* iCalendar dates come in three shapes and conflating them is how an all-day event ends up on the wrong
* day: `20260805` (date only), `20260805T090000Z` (UTC) and `20260805T090000` with a TZID parameter
* (floating/local). Only the first two can be resolved here without a timezone database, so the third is
* returned as-is with `allDay:false` and the UI treats it as local — which is what it means to whoever
* typed it in.
*/
export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null {
const v = prop.value.trim();
const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v);
if (dateOnly) return { iso: `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`, allDay: true };
const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v);
if (!dateTime) return null;
const [, y, mo, d, h, mi, s, z] = dateTime;
return { iso: `${y}-${mo}-${d}T${h}:${mi}:${s}${z ? 'Z' : ''}`, allDay: false };
}
export type CalendarEvent = {
uid: string;
summary: string;
description?: string;
location?: string;
start?: string;
end?: string;
allDay: boolean;
/** Raw RRULE, unexpanded. Present means "this repeats"; the UI must not infer occurrences from it. */
rrule?: string;
status?: string;
};
/** Every VEVENT in one .ics. A file usually holds one, plus overrides for a recurring series. */
export function parseEvents(ics: string): CalendarEvent[] {
const events: CalendarEvent[] = [];
let current: CalendarEvent | null = null;
// Only VEVENT is collected; a VALARM or VTIMEZONE nested inside must not have its properties
// attributed to the event that contains it.
let depth: string[] = [];
for (const line of unfold(ics)) {
const prop = parseLine(line);
if (!prop) continue;
if (prop.name === 'BEGIN') {
depth.push(prop.value.toUpperCase());
if (prop.value.toUpperCase() === 'VEVENT') current = { uid: '', summary: '', allDay: false };
continue;
}
if (prop.name === 'END') {
if (prop.value.toUpperCase() === 'VEVENT' && current) {
events.push(current);
current = null;
}
depth.pop();
continue;
}
if (!current || depth[depth.length - 1] !== 'VEVENT') continue;
switch (prop.name) {
case 'UID':
current.uid = prop.value;
break;
case 'SUMMARY':
current.summary = unescapeText(prop.value);
break;
case 'DESCRIPTION':
current.description = unescapeText(prop.value);
break;
case 'LOCATION':
current.location = unescapeText(prop.value);
break;
case 'STATUS':
current.status = prop.value;
break;
case 'RRULE':
current.rrule = prop.value;
break;
case 'DTSTART': {
const parsed = parseDate(prop);
if (parsed) {
current.start = parsed.iso;
current.allDay = parsed.allDay;
}
break;
}
case 'DTEND': {
const parsed = parseDate(prop);
if (parsed) current.end = parsed.iso;
break;
}
}
}
return events;
}
export type Contact = {
uid: string;
fullName: string;
emails: string[];
phones: string[];
org?: string;
title?: string;
/** Present when the card carries an embedded photo; the bytes are not inlined into list responses. */
hasPhoto: boolean;
};
/** Every VCARD in one .vcf. */
export function parseContacts(vcf: string): Contact[] {
const contacts: Contact[] = [];
let current: Contact | null = null;
for (const line of unfold(vcf)) {
const prop = parseLine(line);
if (!prop) continue;
if (prop.name === 'BEGIN' && prop.value.toUpperCase() === 'VCARD') {
current = { uid: '', fullName: '', emails: [], phones: [], hasPhoto: false };
continue;
}
if (prop.name === 'END' && prop.value.toUpperCase() === 'VCARD') {
if (current) contacts.push(current);
current = null;
continue;
}
if (!current) continue;
switch (prop.name) {
case 'UID':
current.uid = prop.value;
break;
case 'FN':
current.fullName = unescapeText(prop.value);
break;
case 'EMAIL':
current.emails.push(prop.value.trim());
break;
case 'TEL':
current.phones.push(prop.value.trim());
break;
case 'ORG':
// ORG is structured: `Company;Department`. The first component is the one a list shows.
current.org = unescapeText(prop.value.split(';')[0] ?? '');
break;
case 'TITLE':
current.title = unescapeText(prop.value);
break;
case 'PHOTO':
current.hasPhoto = true;
break;
}
}
// A card with no FN is legal but unshowable; fall back to the first email so it is at least findable.
for (const contact of contacts) {
if (!contact.fullName) contact.fullName = contact.emails[0] ?? contact.uid ?? 'Unnamed';
}
return contacts;
}
+251
View File
@@ -0,0 +1,251 @@
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'));
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
import { resolveRadicale } from './radicale';
// The failure this covers took a whole sidecar down on a fresh install: `spawn('radicale')` resolves
// against the PATH the process STARTED with, which under PM2 has no `~/.local/bin`, and the ENOENT threw
// at module load rather than being handled.
describe('resolving radicale', () => {
test('finds the apt-installed binary', () => {
const found = resolveRadicale('/usr/bin:/bin');
// Skip rather than fail where radicale genuinely is not installed — this asserts the RESOLVER, and a
// machine without the package would otherwise report a bug that is not there.
if (!found) return;
expect(found.command).toContain('radicale');
expect(found.args).toEqual([]);
});
test('falls back to the python module when no wrapper script is on PATH', () => {
// A `pip install --user` into an environment without ~/.local/bin, or a distro shipping the module
// with no script, both leave radicale importable and unfindable. The module is what matters.
const pythonOnly = '/usr/bin';
const found = resolveRadicale(pythonOnly);
if (!found) return;
// Either form is correct here; what must never happen is null while the module is importable.
expect(found.command.length).toBeGreaterThan(0);
});
test('returns null rather than throwing when nothing is installed', () => {
// The whole point: a missing dependency must be a value the caller can handle, not an exception at
// module load that crash-loops the sidecar and takes /_health down with it.
expect(() => resolveRadicale('/nonexistent-dir')).not.toThrow();
expect(resolveRadicale('/nonexistent-dir')).toBeNull();
});
});
+178
View File
@@ -0,0 +1,178 @@
import { spawn, spawnSync } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
// Supervises Radicale, the CalDAV/CardDAV server this sidecar fronts.
//
// WHY A REAL SERVER AND NOT OUR OWN. See docs/nextcloud-replacement.md. The short version: iCalendar and
// vCard are easy, but `sync-collection` REPORT, RRULE expansion, VTIMEZONE and ctag/etag semantics are
// not — and when they are subtly wrong a phone does not error, it silently stops syncing or silently
// duplicates every event. Radicale has been getting that right since 2008.
//
// AUTH. Radicale runs with `type = http_x_remote_user`, meaning it trusts an `X-Remote-User` header
// completely and does no authentication of its own. That is safe here for exactly the reason every
// other sidecar's `X-Officer-User` is safe: it binds LOOPBACK ONLY, so nothing but this sidecar can
// reach it, and this sidecar is only reachable through the platform, which is the thing that
// authenticates. If Radicale is ever bound to a non-loopback interface this becomes an open door —
// hence the explicit hostname below rather than a default.
export type Launcher = { command: string; args: string[] };
export type RadicaleHandle = {
port: number;
url: string;
stop: () => void;
isRunning: () => boolean;
/** Why it never started, when it never started. Null once it has launched at least once. */
startupError: string | null;
};
// ── Finding radicale ──
//
// `spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and under PM2 that is a
// login-less minimum. The same trap has now bitten `tea`, the plugin dependency probe and this — so the
// search path is explicit rather than inherited.
//
// `/usr/bin` is listed even though it is almost always in PATH already: this is the apt package's
// location, it is the supported install, and a PATH mangled by a shell profile should not be able to
// break the normal case.
const searchPath = (): string =>
[join(homedir(), '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH ?? '']
.filter(Boolean)
.join(':');
/**
* How to start radicale here, or null if it is not installed at all.
*
* Falls back to `python3 -m radicale` because radicale is a python package: a `pip install --user` in an
* environment without `~/.local/bin` on PATH, or a distribution that ships the module without a wrapper
* script, both leave it importable and unfindable. The module is what actually matters.
*/
export function resolveRadicale(pathOverride?: string): Launcher | null {
const PATH = pathOverride ?? searchPath();
const direct = Bun.which('radicale', { PATH });
if (direct) return { command: direct, args: [] };
const python = Bun.which('python3', { PATH });
if (python) {
const probe = spawnSync(python, ['-c', 'import radicale'], { stdio: 'ignore', env: { PATH } });
if (probe.status === 0) return { command: python, args: ['-m', 'radicale'] };
}
return null;
}
type StartParams = {
/** Where collections live. Created if absent. */
storagePath: string;
/** Where the generated config is written. Never hand-edit it; it is rewritten on every boot. */
configPath: string;
port: number;
};
// Radicale's own config format. Regenerated at every boot on purpose: the config is derived state, and
// a hand-edit that silently disagreed with what this file thinks is true would be very hard to debug.
const configFor = (storagePath: string, port: number) => `# GENERATED by officer-caldav on every boot.
# Hand edits are lost. See src/servers/sidecar/caldav/radicale.ts.
[server]
hosts = 127.0.0.1:${port}
# The platform terminates TLS; this hop is loopback.
ssl = False
[auth]
# Trusts X-Remote-User outright. Safe ONLY because of the loopback bind above — see the note in
# radicale.ts before changing either.
type = http_x_remote_user
[storage]
type = multifilesystem
filesystem_folder = ${storagePath}
[rights]
# The owner is the only principal that exists; single-user is a platform invariant.
type = owner_only
[logging]
level = warning
`;
export function startRadicale({ storagePath, configPath, port }: StartParams): RadicaleHandle {
mkdirSync(storagePath, { recursive: true });
writeFileSync(configPath, configFor(storagePath, port));
let child: ChildProcess | null = null;
let stopped = false;
let restarts = 0;
// Resolved ONCE, before anything can throw. This used to be a bare `spawn('radicale')` at module top
// level, so on a machine without it the ENOENT killed the sidecar and PM2 crash-looped it — which is
// the opposite of what the backoff below is for, and it took `/_health` down with it. A missing
// dependency should be a sidecar that answers "radicale is not installed", not a restart loop whose
// logs say ENOENT and nothing about the fix.
const launcher = resolveRadicale();
if (!launcher) {
const message =
'radicale is not installed. It ships with machine-setup; install it with ' +
'`sudo apt install radicale` (Debian/Ubuntu), `brew install radicale` (macOS), ' +
'or `pip install --user radicale`. Calendar and contacts are unavailable until then.';
console.error(`[caldav] ${message}`);
return {
port,
url: `http://127.0.0.1:${port}`,
isRunning: () => false,
startupError: message,
stop: () => {},
};
}
const launch = () => {
if (stopped) return;
child = spawn(launcher.command, [...launcher.args, '--config', configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
// Radicale must not inherit the platform's environment wholesale — Bun auto-loads .env into every
// process started in the platform directory, and there is no reason for a calendar server to hold
// an Anthropic key or a database URL.
// The augmented PATH, not the inherited one — `python3 -m radicale` needs to find its own
// interpreter's neighbours, and a wrapper script may shell out.
env: { PATH: searchPath(), HOME: process.env.HOME ?? '' },
});
child.stdout?.on('data', (buf: Buffer) => process.stdout.write(`[radicale] ${buf}`));
child.stderr?.on('data', (buf: Buffer) => process.stderr.write(`[radicale] ${buf}`));
child.on('exit', (code, signal) => {
child = null;
if (stopped) return;
// Backoff, capped. A crash loop should be visible in the logs rather than a busy spin, and the
// sidecar itself stays up so /_health can keep reporting the truth.
restarts += 1;
const delay = Math.min(30_000, 500 * 2 ** Math.min(restarts, 6));
console.error(`[caldav] radicale exited (code=${code} signal=${signal}); restarting in ${delay}ms`);
setTimeout(launch, delay);
});
};
launch();
return {
port,
url: `http://127.0.0.1:${port}`,
isRunning: () => child !== null,
startupError: null,
stop: () => {
stopped = true;
child?.kill('SIGTERM');
child = null;
},
};
}
/** Resolve the paths this sidecar owns under DATA_PATH. The platform never opens any of them. */
export function davPaths(dataPath: string) {
const root = join(dataPath, 'dav');
return { root, storagePath: join(root, 'collections'), configPath: join(root, 'radicale.conf') };
}
+226
View File
@@ -0,0 +1,226 @@
import type { FormEvent } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
//
// The generated password is returned by POST and never again — it is stored as an argon2 hash, so there
// is nothing to read back. That single fact drives the whole design of this screen: the new credential
// is shown in a panel that stays put until dismissed, with the server URL and username beside it,
// because the moment it disappears the only remedy is to revoke and mint another.
type DavPassword = {
id: number;
label: string;
hint: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
};
type Minted = { password: string; username: string; label: string };
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
export const AppPasswords = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.dav.copied', { what }));
} catch {
toast.error(t('settings.dav.copyFailed'));
}
};
const client = useClient();
const [passwords, setPasswords] = useState<DavPassword[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [label, setLabel] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [minted, setMinted] = useState<Minted | null>(null);
// The base the phone should be pointed at. Taken from the browser rather than configured: it is by
// definition the address that reached this page, which is the one that will work.
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const load = useCallback(() => {
client
.get<{ passwords: DavPassword[] }>('/caldav/passwords')
.then((data) => setPasswords(data?.passwords ?? []))
.catch(() => {})
.finally(() => setIsLoading(false));
}, [client]);
useEffect(load, [load]);
const create = async (ev?: FormEvent) => {
ev?.preventDefault();
const trimmed = label.trim();
if (!trimmed || isCreating) return;
setIsCreating(true);
try {
const res = await client.post<{ password: string; username: string }>('/caldav/passwords', { label: trimmed });
setMinted({ password: res.password, username: res.username, label: trimmed });
setLabel('');
load();
} catch {
toast.error(t('settings.dav.createFailed'));
} finally {
setIsCreating(false);
}
};
const revoke = async (id: number) => {
try {
await client.post(`/caldav/passwords/${id}/revoke`, {});
toast.success(t('settings.dav.revoked'));
load();
} catch {
toast.error(t('settings.dav.revokeFailed'));
}
};
const remove = async (id: number) => {
try {
await client.delete(`/caldav/passwords/${id}`);
load();
} catch {
toast.error(t('settings.dav.deleteFailed'));
}
};
if (isLoading) return null;
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
Calendar and contacts sync uses one password per device. A phone can't hold an Officer session, so it gets its
own credential — revocable on its own, without touching anything else.
</p>
{minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
Password for “{minted.label}” — shown once
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
revoke this entry and make another.
</p>
<div className="grid gap-2">
{[
{ field: 'Server', value: serverUrl },
{ field: 'Username', value: minted.username },
{ field: 'Password', value: minted.password },
].map(({ field, value }) => (
<div key={field} className="flex items-center gap-2">
<span className="w-20 shrink-0 text-xs text-duck-dark/50 dark:text-foreground/50">{field}</span>
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{value}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copy(value, field)}
title={t('settings.dav.copyField', { field })}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
In DAVx5 or iOS, choose “login with URL and username” and give it the server address above — not a full
calendar path. Discovery does the rest.
</p>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it
</Button>
</div>
)}
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={label}
onChange={(ev) => setLabel(ev.target.value)}
placeholder={t('settings.dav.placeholder')}
/>
</Label>
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
{t('settings.dav.create')}
</Button>
</form>
{passwords.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{t('settings.dav.none')}
</div>
) : (
<div className="grid gap-2">
{passwords.map((entry) => {
const revoked = !!entry.revokedAt;
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
className={`flex items-center gap-3 rounded-lg border p-3 ${
revoked
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
: 'border-duck-dark/15 dark:border-foreground/15'
}`}
>
<Smartphone className="h-4 w-4 shrink-0 text-duck-teal/70" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.label}
{revoked && <span className="ml-2 text-xs text-red-500">revoked</span>}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.hint}…</code>
{' · '}
{/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'}
</div>
</div>
{!revoked && (
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => remove(entry.id)}
title={t('settings.dav.delete')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
};
+30
View File
@@ -0,0 +1,30 @@
import { useGlobal } from 'hooks/useGlobal';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AppPasswords } from './AppPasswords';
/*
Sync setup, as a modal rather than a settings page or a panel.
It was a section in the platform's Settings → Integrations, which a plugin cannot contribute to: a plugin
contributes panels and nothing else. A panel was the obvious replacement and does not work either —
`PluginScreen` renders the workspace `locked`, so a panel outside the default layout is unreachable and
one inside it would spend a fifth of the screen on a form you touch twice a year.
So it follows the email plugin, which had already solved exactly this: a global holds "is it open", any
panel can set it, and the panel that mounts the modal renders it once.
*/
export const useAppPasswordsModal = () => useGlobal<boolean>('CALDAV_APP_PASSWORDS_OPEN', false);
export const AppPasswordsModal = () => {
const [open, setOpen] = useAppPasswordsModal();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Sync to your phone</DialogTitle>
</DialogHeader>
<AppPasswords />
</DialogContent>
</Dialog>
);
};
+13
View File
@@ -0,0 +1,13 @@
import type { LayoutNode } from 'officerdev';
// Unchanged from the core screen this replaced, ids included — the layout persists under
// `screens/plugin/caldav/calendar` and a user's sizes are keyed on the group id.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'calendar-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 },
{ node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 },
],
};
+30
View File
@@ -0,0 +1,30 @@
import type { AppRegistryMeta } from 'officerdev';
import { CalendarDays, PanelLeft } from 'lucide-react';
import { panelName } from 'officerdev';
import { CalendarNav } from '../dav/CollectionNav';
import { CalendarView } from '../dav/CalendarView';
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
//
// The components live one level up in `dav/`, shared with the contacts screen: they are the same sidecar,
// the same collection list and the same selection rule, differing only in which kind of collection they
// show. Two screens, one implementation.
//
// `panelName` still resolves through the platform's catalogs, so these keep the six translations they had
// as core screens. See the note beside its export in officerdev's barrel.
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'calendar-nav',
name: panelName('panelName.calendar'),
icon: PanelLeft,
component: CalendarNav,
availableOnPanel: false,
},
{
key: 'calendar-view',
name: panelName('panelName.calendar'),
icon: CalendarDays,
component: CalendarView,
availableOnPanel: false,
},
];
+13
View File
@@ -0,0 +1,13 @@
import type { LayoutNode } from 'officerdev';
// Unchanged from the core screen this replaced, ids included — the layout persists under
// `screens/plugin/caldav/contacts` and a user's sizes are keyed on the group id.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'contacts-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 },
{ node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 },
],
};
+23
View File
@@ -0,0 +1,23 @@
import type { AppRegistryMeta } from 'officerdev';
import { Contact as ContactIcon, PanelLeft } from 'lucide-react';
import { panelName } from 'officerdev';
import { ContactsNav } from '../dav/CollectionNav';
import { ContactsView } from '../dav/ContactsView';
// See ../calendar/panels.ts — the components are shared between both screens.
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'contacts-nav',
name: panelName('panelName.contacts'),
icon: PanelLeft,
component: ContactsNav,
availableOnPanel: false,
},
{
key: 'contacts-view',
name: panelName('panelName.contacts'),
icon: ContactIcon,
component: ContactsView,
availableOnPanel: false,
},
];
+101
View File
@@ -0,0 +1,101 @@
import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
import { useTranslation } from 'officerdev';
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
// never claimed existed. A list can show a repeating event honestly, as one entry with a "repeats" badge.
const dayKey = (ev: CalendarEvent) => (ev.start ? ev.start.slice(0, 10) : '');
const formatDay = (iso: string) => {
if (!iso) return 'No date';
const date = new Date(`${iso}T12:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
};
const formatTime = (ev: CalendarEvent) => {
if (ev.allDay || !ev.start) return 'All day';
const start = new Date(ev.start);
if (Number.isNaN(start.getTime())) return '';
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
const end = ev.end ? new Date(ev.end) : null;
if (!end || Number.isNaN(end.getTime())) return start.toLocaleTimeString(undefined, opts);
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
};
export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
for (const ev of sorted) {
const key = dayKey(ev);
const last = days[days.length - 1];
if (last && last.key === key) last.events.push(ev);
else days.push({ key, events: [ev] });
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{days.map((day) => (
<section key={day.key} className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{formatDay(day.key)}
</h2>
{day.events.map((ev) => (
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
<span
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: selected.color || '#94a3b8' }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
{ev.rrule && (
<span
title={ev.rrule}
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
<Repeat className="h-3 w-3" />
repeats
</span>
)}
</div>
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
{ev.location && (
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{ev.location}</span>
</div>
)}
{ev.description && (
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
)}
</div>
</article>
))}
</section>
))}
</div>
</div>
);
};
const Placeholder = ({ text }: { text: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground">
<CalendarDays className="h-6 w-6 opacity-40" />
<span>{text}</span>
</div>
);
+104
View File
@@ -0,0 +1,104 @@
import type { LucideIcon } from 'lucide-react';
import type { DavCollection } from './shared';
import { Link, useSearchParams } from 'react-router';
import { CalendarDays, Contact as ContactIcon, Smartphone } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData';
import { AppPasswordsModal, useAppPasswordsModal } from '../AppPasswordsModal';
import { useTranslation } from 'officerdev';
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
//
// Rows are real links carrying ?collection=<dav path>, so a calendar is a bookmarkable address and the
// right-hand panel reads the same URL rather than being told over a channel. Active state is compared
// against the URL directly instead of NavLink's isActive, because every row shares one pathname and
// differs only in the query string.
type Props = {
kind: DavCollection['kind'];
base: '/calendar' | '/contacts';
title: string;
icon: LucideIcon;
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
const { t } = useTranslation();
const [, setSyncOpen] = useAppPasswordsModal();
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
// Nothing in the URL means the view falls back to the first collection — highlight the same one.
const activePath = collections.some((c) => c.path === fromUrl) ? fromUrl : (collections[0]?.path ?? null);
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500 ring-1 ring-black/5">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">{title}</div>
<div className="text-xs text-muted-foreground">
{isLoading
? 'loading…'
: `${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}`}
</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{collections.map((collection) => {
const isActive = collection.path === activePath;
return (
<Link
key={collection.path}
to={davCollectionPath(base, collection.path)}
className={`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<span
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: collection.color || '#94a3b8' }}
/>
<span className="flex-1 truncate">{collection.displayName}</span>
</Link>
);
})}
{!isLoading && collections.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
)}
</nav>
{/* Sync setup lives at the bottom of the nav, and the modal is mounted here once.
It was Settings → Integrations → DAV sync until caldav became a plugin; a plugin ships panels and
cannot contribute a settings section, so it follows email's AccountsModal. Pushed down by
`mt-auto` because it is a twice-a-year task that should not compete with the collections. */}
<button
onClick={() => setSyncOpen(true)}
className="mt-auto flex items-center gap-2 border-t px-4 py-3 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Smartphone className="h-3.5 w-3.5 shrink-0" />
Sync to your phone
</button>
<AppPasswordsModal />
</div>
);
};
export const CalendarNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
};
export const ContactsNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
};
+81
View File
@@ -0,0 +1,81 @@
import { Building2, Mail, Phone, User } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData';
import { useTranslation } from 'officerdev';
// Right panel of /contacts: the selected address book as cards, sorted by name.
//
// `hasPhoto` is a flag, not the bytes — the sidecar keeps embedded photos out of the list response so a
// book with a hundred cards is not a hundred base64 blobs. Until there is an endpoint that serves one
// card's photo, an initial stands in.
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || '?';
export const ContactsView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading address books…" />;
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading contacts…" />;
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
{sorted.map((contact) => (
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
{initials(contact.fullName)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
{(contact.title || contact.org) && (
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Building2 className="h-3 w-3 shrink-0" />
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
</div>
)}
{contact.emails.map((email) => (
<a
key={email}
href={`mailto:${email}`}
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{email}</span>
</a>
))}
{contact.phones.map((phone) => (
<a
key={phone}
href={`tel:${phone}`}
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
>
<Phone className="h-3 w-3 shrink-0" />
<span className="truncate">{phone}</span>
</a>
))}
</div>
</article>
))}
</div>
</div>
);
};
const Placeholder = ({ text }: { text: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground">
<User className="h-6 w-6 opacity-40" />
<span>{text}</span>
</div>
);
+47
View File
@@ -0,0 +1,47 @@
// Shared wire shapes for the /calendar and /contacts workspaces.
//
// These mirror what the officer-caldav sidecar returns from its JSON door under /api/caldav/_officer/* —
// see src/servers/sidecar/caldav/{collections,ical}.ts, which is where they are produced. The sidecar
// parses the iCalendar/vCard itself so nothing here ever sees DAV XML.
/** One calendar or address book. `path` is the DAV path (/dav/<userId>/<name>/) and doubles as the id. */
export type DavCollection = {
path: string;
displayName: string;
kind: 'calendar' | 'addressbook';
color?: string;
};
export type CalendarEvent = {
uid: string;
summary: string;
description?: string;
location?: string;
start?: string;
end?: string;
allDay: boolean;
/** Raw RRULE, unexpanded — the sidecar does not expand a series, so this is a badge, not occurrences. */
rrule?: string;
status?: string;
};
export type DavContact = {
uid: string;
fullName: string;
emails: string[];
phones: string[];
org?: string;
title?: string;
hasPhoto: boolean;
};
/**
* The query param both screens select with. A collection id contains slashes, so it cannot be a path
* segment without escaping — and per the navigation rules the selection still has to live in the URL,
* so it is a query param on the screen you are already on.
*/
export const COLLECTION_PARAM = 'collection';
/** The one place the two screens' URLs are spelled, so nav links and guards cannot drift. */
export const davCollectionPath = (base: '/calendar' | '/contacts', collection: string) =>
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(collection)}`;
+76
View File
@@ -0,0 +1,76 @@
import type { CalendarEvent, DavCollection, DavContact } from './shared';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { COLLECTION_PARAM } from './shared';
// Data layer for /calendar and /contacts. Everything goes through the /api/caldav auth proxy to the
// officer-caldav sidecar, which holds the Radicale process and every DAV credential; officer forwards the
// authenticated user's id and nothing else.
//
// There is no push channel — Radicale is a plain DAV server — so these are ordinary queries with a
// generous staleTime rather than a poll. A calendar changes when someone edits it, not on a timer.
const STALE_MS = 30_000;
const EMPTY_COLLECTIONS: DavCollection[] = [];
const EMPTY_EVENTS: CalendarEvent[] = [];
const EMPTY_CONTACTS: DavContact[] = [];
export function useDavCollections(kind?: DavCollection['kind']) {
const { get } = useClient();
const query = useQuery({
queryKey: ['caldav', 'collections'],
queryFn: () => get<{ collections: DavCollection[] }>('/caldav/_officer/collections'),
staleTime: STALE_MS,
});
const all = query.data?.collections ?? EMPTY_COLLECTIONS;
return {
collections: kind ? all.filter((c) => c.kind === kind) : all,
isLoading: query.isLoading,
error: query.error,
};
}
export function useCalendarEvents(collection: string | null) {
const { get } = useClient();
const query = useQuery({
queryKey: ['caldav', 'events', collection],
queryFn: () =>
get<{ events: CalendarEvent[] }>(`/caldav/_officer/events?collection=${encodeURIComponent(collection ?? '')}`),
enabled: Boolean(collection),
staleTime: STALE_MS,
});
return { events: query.data?.events ?? EMPTY_EVENTS, isLoading: query.isLoading, error: query.error };
}
export function useDavContacts(collection: string | null) {
const { get } = useClient();
const query = useQuery({
queryKey: ['caldav', 'contacts', collection],
queryFn: () =>
get<{ contacts: DavContact[] }>(`/caldav/_officer/contacts?collection=${encodeURIComponent(collection ?? '')}`),
enabled: Boolean(collection),
staleTime: STALE_MS,
});
return { contacts: query.data?.contacts ?? EMPTY_CONTACTS, isLoading: query.isLoading, error: query.error };
}
/**
* The collection the URL selects, or the first one of that kind once the list arrives. Both panels of a
* screen call this and get the same answer, because the answer is derived from the URL and the query
* cache rather than passed between them.
*/
export function useSelectedCollection(kind: DavCollection['kind']) {
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
const selected = collections.find((c) => c.path === fromUrl) ?? collections[0] ?? null;
return { selected, collections, isLoading };
}