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
83 lines
4.0 KiB
TypeScript
83 lines
4.0 KiB
TypeScript
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);
|