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:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user