mints a dav app password, renders a configuration profile carrying both the caldav and carddav payloads, and parks it behind a single-use five-minute token that safari can fetch without a session. one profile with both payloads is not a convenience: ios keys accounts by server+username, so adding carddav separately gets folded into the existing caldav account and contacts silently never appear. the profile holds the password in plaintext, so it is held in memory only — persisting it would falsify createDavAppPassword's "not stored" guarantee. signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default; this box has no tls certificate, tls terminates upstream. signed at mint time reading the cert from disk, so a renewal needs no restart and no hook. the download route is registered before the /dav mount because hono matches in registration order and the sync door's /* would otherwise demand http basic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { cors } from 'hono/cors';
|
|
import { createRouter } from './create-router';
|
|
import type { HonoVariables } from './create-router';
|
|
import { authRouter } from './api/auth';
|
|
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
|
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
|
import { waitlistRouter } from './api/waitlist/waitlist';
|
|
import { usersRouter } from './api/users/users-router';
|
|
import { plansRouter } from './api/plans/plans';
|
|
import { skillsRouter } from './api/skills/skills';
|
|
import { tasksRouter } from './api/tasks/tasks';
|
|
import { agentsRouter } from './api/agents/agents';
|
|
import { toolsRouter } from './api/tools/tools';
|
|
import { processesRouter } from './api/processes/processes';
|
|
import { rescanRouter } from './api/items/rescan';
|
|
import { scrapeRouter } from './api/scrape/scrape';
|
|
import { uploadRouter } from './api/upload/upload';
|
|
import { settingsRouter } from './api/settings/settings';
|
|
import { dashboardsRouter } from './api/dashboards';
|
|
import { taskLogsRouter } from './api/task-logs/task-logs';
|
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
|
import { musicRouter } from './api/music/router';
|
|
import { vaultRouter } from './api/vault/router';
|
|
import { slskdRouter } from './api/slskd/router';
|
|
import { headscaleRouter } from './api/headscale/router';
|
|
import { transmissionRouter } from './api/transmission/router';
|
|
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
|
import { photosRouter } from './api/photos/router';
|
|
import { walletRouter } from './api/wallet/router';
|
|
import { vpnRouter } from './api/vpn/router';
|
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
|
import { caldavRouter } from './api/dav/sidecar-server';
|
|
import { memosRouter } from './api/memos/router';
|
|
import { davSyncRouter } from './api/dav/sync-router';
|
|
import { davRouter } from './api/dav/router';
|
|
import { claimIosProfile } from './api/dav/ios-profile';
|
|
import { notifyRouter } from './api/notify/router';
|
|
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
|
import { activityRouter } from './api/activity/router';
|
|
// Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar
|
|
// registers its listener when createSidecarProxy runs inside the router this file already imports.
|
|
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
|
import { dockRouter } from './api/dock/dock';
|
|
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
|
import { queueRouter } from './api/queue/queue';
|
|
import { emailRouter } from './api/email/router';
|
|
import { browserRouter } from './api/browser/router';
|
|
import { desktopRouter } from './api/desktop/rest';
|
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
|
import { chatRouter } from './api/chat/chat';
|
|
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
|
import { CustomError } from './custom-errors';
|
|
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
|
|
import { isMusicOriginExempt } from './_middlewares/origin-validation';
|
|
|
|
export { Hono };
|
|
export { createRouter };
|
|
export type { HonoVariables };
|
|
|
|
export const honoServer = new Hono<{ Variables: HonoVariables }>();
|
|
|
|
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.
|
|
honoServer.use(originScopeMiddleware);
|
|
|
|
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
|
honoServer.route('/api/auth', authRouter);
|
|
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
|
honoServer.route('/api/waitlist', waitlistRouter);
|
|
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
|
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. Origin
|
|
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
|
|
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
|
honoServer.route('/api/vault', vaultRouter);
|
|
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
|
|
|
// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is:
|
|
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
|
|
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
|
|
// api/dav/sync-router.ts.
|
|
// The iOS profile download, registered BEFORE the /dav 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/dav/provision/ios; see api/dav/ios-profile.ts.
|
|
honoServer.get('/dav/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',
|
|
},
|
|
});
|
|
});
|
|
|
|
honoServer.route('/dav', davSyncRouter);
|
|
|
|
// Autodiscovery. This is 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 paths UNAUTHENTICATED before it has any
|
|
// 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.
|
|
// `.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());
|
|
protectedRouter.use(userMiddleware);
|
|
|
|
protectedRouter.route('/server-settings', serverSettingsRouter);
|
|
protectedRouter.route('/users', usersRouter);
|
|
protectedRouter.route('/plans', plansRouter);
|
|
protectedRouter.route('/skills', skillsRouter);
|
|
protectedRouter.route('/tasks', tasksRouter);
|
|
protectedRouter.route('/agents', agentsRouter);
|
|
protectedRouter.route('/tools', toolsRouter);
|
|
protectedRouter.route('/processes', processesRouter);
|
|
protectedRouter.route('/rescan', rescanRouter);
|
|
protectedRouter.route('/scrape', scrapeRouter);
|
|
protectedRouter.route('/upload', uploadRouter);
|
|
protectedRouter.route('/user', settingsRouter);
|
|
protectedRouter.route('/dashboards', dashboardsRouter);
|
|
protectedRouter.route('/task-logs', taskLogsRouter);
|
|
protectedRouter.route('/file-browser', fileBrowserRouter);
|
|
protectedRouter.route('/music', musicRouter);
|
|
protectedRouter.route('/slskd', slskdRouter);
|
|
protectedRouter.route('/terminal', terminalRouter);
|
|
protectedRouter.route('/memos', memosRouter);
|
|
protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI
|
|
protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level)
|
|
protectedRouter.route('/notify', notifyRouter);
|
|
protectedRouter.route('/headscale', headscaleRouter);
|
|
protectedRouter.route('/transmission', transmissionRouter);
|
|
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
|
protectedRouter.route('/photos', photosRouter);
|
|
protectedRouter.route('/wallet', walletRouter);
|
|
protectedRouter.route('/vpn', vpnRouter);
|
|
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
|
protectedRouter.route('/activity', activityRouter);
|
|
protectedRouter.route('/dock', dockRouter);
|
|
protectedRouter.route('/integrations', integrationsRouter);
|
|
protectedRouter.route('/queue', queueRouter);
|
|
protectedRouter.route('/email', emailRouter);
|
|
protectedRouter.route('/browser', browserRouter);
|
|
protectedRouter.route('/bug-report', bugReportRouter);
|
|
protectedRouter.route('/chat', chatRouter);
|
|
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
|
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
|
protectedRouter.route('/desktop', desktopRouter);
|
|
|
|
honoServer.route('/api', protectedRouter);
|
|
|
|
honoServer.onError((error, ctx) => {
|
|
if (error instanceof CustomError) {
|
|
if (error.returnValue) {
|
|
if (typeof error.returnValue === 'string') {
|
|
return ctx.text(error.returnValue, error.statusCode);
|
|
} else {
|
|
return ctx.json(error.returnValue, error.statusCode);
|
|
}
|
|
}
|
|
return ctx.text(error.message, error.statusCode);
|
|
}
|
|
|
|
console.error('Unexpected error:', error.message);
|
|
console.log(error.stack);
|
|
return ctx.text('Internal Server Error', 500);
|
|
});
|