diff --git a/src/servers/hono.ts b/src/servers/hono.ts index b1347930..a9455934 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -59,19 +59,31 @@ export type { HonoVariables }; export const honoServer = new Hono<{ Variables: HonoVariables }>(); -honoServer.use( - cors({ - origin: (origin, c) => { - const host = c.req.header('host'); - // TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a - // browser client is not blocked by CORS after userMiddleware has already let it through. - if (isMusicOriginExempt(c.req.path)) return origin ?? '*'; - return isOriginAllowed(origin, host) ? origin : ''; - }, - allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - allowHeaders: ['Content-Type', 'Authorization'], - }), -); +const corsMiddleware = cors({ + origin: (origin, c) => { + const host = c.req.header('host'); + // TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a + // browser client is not blocked by CORS after userMiddleware has already let it through. + if (isMusicOriginExempt(c.req.path)) return origin ?? '*'; + return isOriginAllowed(origin, host) ? origin : ''; + }, + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + allowHeaders: ['Content-Type', 'Authorization'], +}); + +// The DAV doors are the one place CORS must NOT run, because hono's cors() answers every OPTIONS itself +// as a preflight — 204, no body, no DAV headers — and never calls the route beneath it. +// +// OPTIONS is not a preflight to a DAV client. It is how the client asks what the server can do, and the +// answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook` header. iOS sends it during account +// setup and refuses the account when it is missing, reporting the failure as "Cannot connect using SSL" — +// a message about TLS for a problem that has nothing to do with TLS, which is how this cost an evening. +// +// Nothing is lost by skipping it: a CalDAV client is not a browser and has no origin to check. +const isDavPath = (path: string) => + path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav'; + +honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next))); // Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes // (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset. @@ -99,8 +111,11 @@ honoServer.route('/dav', davSyncRouter); // credential, so they must sit above every auth gate. Without them iOS in particular degrades to // demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel // worse than the commercial product it is replacing. -honoServer.get('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); -honoServer.get('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); +// `.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. +honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); +honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); const protectedRouter = createRouter(); protectedRouter.use(bodyParser());