import { app, net } from 'electron'; import { DataSource } from 'typeorm'; import { buildQueryHandlers } from '../cqrs/queries'; import { QueryType, QueryTypeKey, Query } from '../cqrs/types'; import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store'; import { buildOpenApiDocument } from './openapi'; import { HttpError, RequestContext, readJsonBody } from './http-helpers'; import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html'; import { LocalApiSettings } from '../desktop-settings'; export interface RouteResponse { status: number; body: unknown; contentType?: string; filePath?: string; rawBody?: string; } export interface RouteContext { request: RequestContext; settings: LocalApiSettings; baseUrl: string; dataSource: DataSource; bodyBuffer: () => Promise; } type RouteHandler = (context: RouteContext) => Promise; interface RouteMatch { handler: RouteHandler; params: Record; requiresAuth: boolean; } interface RouteDefinition { method: string; pattern: RegExp; paramKeys: string[]; handler: RouteHandler; requiresAuth: boolean; } function compilePattern(template: string): { pattern: RegExp; paramKeys: string[] } { const paramKeys: string[] = []; const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => { if (match === '*' || match === '+' || match === '?') return `\\${match}`; return `\\${match}`; }); const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => { paramKeys.push(key); return '([^/]+)'; }); void escaped; return { pattern: new RegExp(`^${source}$`), paramKeys }; } function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean): RouteDefinition { const compiled = compilePattern(template); return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth }; } function runQuery(dataSource: DataSource, query: Query): Promise { const handlers = buildQueryHandlers(dataSource) as Record Promise>; const handler = handlers[query.type as QueryTypeKey]; if (!handler) { throw new HttpError(500, `No handler registered for query: ${query.type}`, 'UNKNOWN_QUERY'); } return handler(query) as Promise; } function clampInt(value: unknown, min: number, max: number, fallback: number): number { const parsed = typeof value === 'string' ? Number(value) : NaN; if (!Number.isFinite(parsed)) return fallback; return Math.max(min, Math.min(max, Math.floor(parsed))); } const ROUTES: RouteDefinition[] = [ defineRoute('GET', '/api/health', async (ctx): Promise => ({ status: 200, body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan } }), false), defineRoute('GET', '/api/openapi.json', async (ctx): Promise => ({ status: 200, body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() }) }), false), defineRoute('GET', '/docs', async (ctx): Promise => { if (!ctx.settings.scalarEnabled) { return { status: 404, body: null, contentType: 'text/plain; charset=utf-8', rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.' }; } return { status: 200, body: null, contentType: 'text/html; charset=utf-8', rawBody: getDocsHtml(`${ctx.baseUrl}/api/openapi.json`) }; }, false), defineRoute('GET', '/scalar/api-reference.js', async (ctx): Promise => { if (!ctx.settings.scalarEnabled) { return { status: 404, body: null, contentType: 'text/plain; charset=utf-8', rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.' }; } const bundlePath = await getScalarApiReferenceBundlePath(); if (!bundlePath) { throw new HttpError(503, 'Scalar API reference bundle is not available in this build', 'SCALAR_BUNDLE_MISSING'); } return { status: 200, body: null, contentType: 'application/javascript; charset=utf-8', filePath: bundlePath }; }, false), defineRoute('POST', '/api/auth/login', async (ctx): Promise => { const body = await ctx.bodyBuffer() as { username?: unknown; password?: unknown; serverUrl?: unknown }; const username = typeof body.username === 'string' ? body.username.trim() : ''; const password = typeof body.password === 'string' ? body.password : ''; const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim().replace(/\/+$/u, '') : ''; if (!username || !password || !serverUrl) { throw new HttpError(400, 'username, password, and serverUrl are required', 'INVALID_REQUEST'); } if (!/^https?:\/\//iu.test(serverUrl)) { throw new HttpError(400, 'serverUrl must be an http or https URL', 'INVALID_REQUEST'); } if (ctx.settings.allowedSignalingServers.length === 0) { throw new HttpError(403, 'No signaling servers are allowed for local API authentication. Add one in desktop settings.', 'NO_ALLOWED_SERVERS'); } if (!ctx.settings.allowedSignalingServers.includes(serverUrl)) { throw new HttpError(403, 'Signaling server URL is not in the allowed list', 'SERVER_NOT_ALLOWED'); } let response: Response; try { response = await net.fetch(`${serverUrl}/api/users/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); } catch (error) { throw new HttpError(502, `Failed to reach signaling server: ${(error as Error).message}`, 'UPSTREAM_UNREACHABLE'); } if (response.status === 401 || response.status === 403) { throw new HttpError(401, 'Invalid credentials', 'INVALID_CREDENTIALS'); } if (!response.ok) { throw new HttpError(502, `Signaling server rejected login (${response.status})`, 'UPSTREAM_ERROR'); } const remote = await response.json() as { id?: string; username?: string; displayName?: string }; if (!remote.id || !remote.username) { throw new HttpError(502, 'Signaling server returned an unexpected response', 'UPSTREAM_BAD_RESPONSE'); } const issued = issueToken({ userId: remote.id, username: remote.username, displayName: remote.displayName ?? remote.username, signalingServerUrl: serverUrl }); return { status: 200, body: { token: issued.token, expiresAt: issued.expiresAt, user: { id: issued.userId, username: issued.username, displayName: issued.displayName } } }; }, false), defineRoute('POST', '/api/auth/logout', async (ctx): Promise => { if (ctx.request.bearerToken) { revokeToken(ctx.request.bearerToken); } return { status: 204, body: null }; }, true), defineRoute('GET', '/api/profile', async (ctx): Promise => { const user = await runQuery(ctx.dataSource, { type: QueryType.GetCurrentUser, payload: {} }); if (!user) { throw new HttpError(404, 'No current user is set on this device', 'NO_CURRENT_USER'); } return { status: 200, body: user }; }, true), defineRoute('GET', '/api/rooms', async (ctx): Promise => { const rooms = await runQuery(ctx.dataSource, { type: QueryType.GetAllRooms, payload: {} }); return { status: 200, body: rooms ?? [] }; }, true), defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise => { const roomId = ctx.request.url.pathname.match(/\/api\/rooms\/([^/]+)\/messages$/u)?.[1]; if (!roomId) throw new HttpError(400, 'roomId is required', 'INVALID_REQUEST'); const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100); const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0); const messages = await runQuery(ctx.dataSource, { type: QueryType.GetMessages, payload: { roomId: decodeURIComponent(roomId), limit, offset } }); return { status: 200, body: messages ?? [] }; }, true) ]; export interface RoutingResult { match: RouteMatch | null; methodNotAllowed: boolean; } export function matchRoute(method: string, pathname: string): RoutingResult { let methodNotAllowed = false; for (const route of ROUTES) { const result = route.pattern.exec(pathname); if (!result) continue; if (route.method !== method) { methodNotAllowed = true; continue; } const params: Record = {}; for (let index = 0; index < route.paramKeys.length; index++) { params[route.paramKeys[index]] = result[index + 1]; } return { match: { handler: route.handler, params, requiresAuth: route.requiresAuth }, methodNotAllowed: false }; } return { match: null, methodNotAllowed }; } export function authenticate(token: string | null): IssuedToken | null { if (!token) return null; return consumeToken(token); }