Files
selfphoneng/apps/api/src/app.ts
T
iamdoubz 0cdac0bcdc api: pair the mobile app with device tokens
MB1 (spec 13). Decision 0005: our own Android/iPhone app, Expo in the
mobile repo, user portal only, no push in v1.

- mobile_devices (migration 0013): one-time pairing codes from My settings
  (QR or server + code, 10 minutes), traded by the app for an spm_ device
  token that acts as its user and never as an admin
- POST /api/me/mobile-devices/pair (web session only), POST /api/mobile/pair,
  GET/DELETE /api/me/mobile-devices
- signing out everywhere (password change or reset, disabling) removes
  paired phones too
- My settings: Mobile app section with the QR code and paired phones
- mobile/ (its own repo) ignored here
2026-10-26 17:12:53 -05:00

197 lines
7.4 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyRateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
import type { Config } from './config.ts';
import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import type { AmiClient } from './ami/client.ts';
import { settingsRoutes } from './routes/settings.ts';
import { HttpError, errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { oidcRoutes } from './routes/oidc.ts';
import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts';
import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
import { mobileRoutes } from './routes/mobile.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
import { conferenceRoutes } from './routes/conferences.ts';
import { pagingRoutes } from './routes/paging.ts';
import { mohRoutes } from './routes/moh.ts';
import { deviceRoutes } from './routes/devices.ts';
import { provisioningRoutes } from './routes/provisioning.ts';
import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts';
import { announcementRoutes } from './routes/announcements.ts';
import { blockedRoutes } from './routes/blocked.ts';
import { callRoutes } from './routes/calls.ts';
import { recordingRoutes } from './routes/recordings.ts';
import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts';
import { reportRoutes } from './routes/reports.ts';
import { aboutRoutes } from './routes/about.ts';
import { contactRoutes } from './routes/contacts.ts';
declare module 'fastify' {
interface FastifyInstance {
db: Db;
config: Config;
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
/** Decorated by the caller too (tests pass a fake). */
ami: Pick<AmiClient, 'action' | 'command' | 'isConnected'>;
}
}
// Paths the SPA must never swallow (04 §1 step 5).
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/** A Linphone setup link's token is the credential (07 §5): keep it out of the request log. */
export const logUrl = (url: string) => url.replace(/^(\/prov\/linphone\/)[^/?]+/i, '$1…');
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
const app = Fastify({
trustProxy: config.TRUST_PROXY ? '127.0.0.1' : false,
logger: {
level: config.LOG_LEVEL,
redact: [
'*.password',
'*.secret',
'*.token',
'*.pin',
'*.Secret',
'req.headers.authorization',
'req.headers.cookie',
],
serializers: {
// Fastify's own req serializer, with the URL masked (M11-T8).
req: (req) => ({
method: req.method,
url: logUrl(req.url),
host: req.host,
remoteAddress: req.ip,
remotePort: req.socket?.remotePort,
}),
},
},
});
app.decorate('db', db);
app.decorate('config', config);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
// Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
// their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
if (config.RATE_LIMIT_PER_MINUTE > 0)
app.register(fastifyRateLimit, {
max: config.RATE_LIMIT_PER_MINUTE,
timeWindow: 60_000,
errorResponseBuilder: (_req, ctx) =>
new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
});
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
// API-token requests (Authorization: Bearer) don't need it: requireAuth then ignores the cookie,
// and a cross-site page can't set Authorization either.
app.addHook('onRequest', async (req, reply) => {
if (
// The matched route, not req.url: the router decodes "/%61pi/…" to /api/… (M11-T8).
(req.routeOptions.url ?? req.url).startsWith('/api/') &&
!SAFE_METHODS.has(req.method) &&
!req.headers.authorization?.startsWith('Bearer ') &&
req.headers['x-requested-with'] !== 'selfphoneng'
)
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
});
// Security headers on every response (08 §2.4). The softphone's WebSocket may live elsewhere
// (WEBRTC_WS_URL, e.g. in dev), so its origin is allowed too. HSTS is the reverse proxy's job.
const wsOrigins = [
`wss://${config.PUBLIC_HOSTNAME}`,
...(config.WEBRTC_WS_URL ? [new URL(config.WEBRTC_WS_URL).origin] : []),
];
const csp = [
"default-src 'self'",
`connect-src 'self' ${[...new Set(wsOrigins)].join(' ')}`,
"media-src 'self' blob:",
"img-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"frame-ancestors 'none'",
].join('; ');
app.addHook('onSend', async (_req, reply) => {
reply.header('Content-Security-Policy', csp);
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Referrer-Policy', 'same-origin');
reply.header('Permissions-Policy', 'microphone=(self), camera=()');
});
app.get('/healthz', async (_req, reply) => {
try {
await db.execute(sql`select 1`);
return { ok: true };
} catch {
return reply.code(503).send({ ok: false, reasons: ['db'] });
}
});
app.register(authRoutes);
app.register(userRoutes);
app.register(oidcRoutes);
app.register(systemRoutes);
app.register(extensionRoutes);
app.register(trunkRoutes);
app.register(didRoutes);
app.register(outboundRouteRoutes);
app.register(settingsRoutes);
app.register(liveRoutes);
app.register(meRoutes);
app.register(mobileRoutes);
app.register(contactRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
app.register(queueRoutes);
app.register(conferenceRoutes);
app.register(pagingRoutes);
app.register(mohRoutes);
app.register(deviceRoutes);
app.register(provisioningRoutes);
app.register(ivrRoutes);
app.register(timeConditionRoutes);
app.register(announcementRoutes);
app.register(blockedRoutes);
app.register(callRoutes);
app.register(recordingRoutes);
app.register(liveCallRoutes);
app.register(integrationRoutes);
app.register(reportRoutes);
app.register(aboutRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
root: publicDir,
serve: existsSync(publicDir),
wildcard: false,
});
app.setNotFoundHandler((req, reply) => {
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
return reply.sendFile('index.html');
return sendError(reply, notFound());
});
return app;
}