295 lines
9.2 KiB
TypeScript
295 lines
9.2 KiB
TypeScript
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<unknown>;
|
|
}
|
|
|
|
type RouteHandler = (context: RouteContext) => Promise<RouteResponse>;
|
|
|
|
interface RouteMatch {
|
|
handler: RouteHandler;
|
|
params: Record<string, string>;
|
|
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<T>(dataSource: DataSource, query: Query): Promise<T> {
|
|
const handlers = buildQueryHandlers(dataSource) as Record<QueryTypeKey, (q: Query) => Promise<unknown>>;
|
|
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<T>;
|
|
}
|
|
|
|
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<RouteResponse> => ({
|
|
status: 200,
|
|
body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan }
|
|
}), false),
|
|
|
|
defineRoute('GET', '/api/openapi.json', async (ctx): Promise<RouteResponse> => ({
|
|
status: 200,
|
|
body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() })
|
|
}), false),
|
|
|
|
defineRoute('GET', '/docs', async (ctx): Promise<RouteResponse> => {
|
|
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<RouteResponse> => {
|
|
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<RouteResponse> => {
|
|
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<RouteResponse> => {
|
|
if (ctx.request.bearerToken) {
|
|
revokeToken(ctx.request.bearerToken);
|
|
}
|
|
|
|
return { status: 204, body: null };
|
|
}, true),
|
|
|
|
defineRoute('GET', '/api/profile', async (ctx): Promise<RouteResponse> => {
|
|
const user = await runQuery<unknown>(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<RouteResponse> => {
|
|
const rooms = await runQuery<unknown[]>(ctx.dataSource, {
|
|
type: QueryType.GetAllRooms,
|
|
payload: {}
|
|
});
|
|
|
|
return { status: 200, body: rooms ?? [] };
|
|
}, true),
|
|
|
|
defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise<RouteResponse> => {
|
|
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<unknown[]>(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<string, string> = {};
|
|
|
|
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);
|
|
}
|