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);