feat: plugins v1.7
This commit is contained in:
70
electron/api/auth-store.ts
Normal file
70
electron/api/auth-store.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export interface IssuedToken {
|
||||
token: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
signalingServerUrl: string;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const tokens = new Map<string, IssuedToken>();
|
||||
|
||||
export function issueToken(params: {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
signalingServerUrl: string;
|
||||
}): IssuedToken {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const issuedAt = Date.now();
|
||||
const issued: IssuedToken = {
|
||||
token,
|
||||
issuedAt,
|
||||
expiresAt: issuedAt + TOKEN_TTL_MS,
|
||||
userId: params.userId,
|
||||
username: params.username,
|
||||
displayName: params.displayName,
|
||||
signalingServerUrl: params.signalingServerUrl
|
||||
};
|
||||
|
||||
tokens.set(token, issued);
|
||||
return issued;
|
||||
}
|
||||
|
||||
export function consumeToken(token: string): IssuedToken | null {
|
||||
const issued = tokens.get(token);
|
||||
|
||||
if (!issued) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (issued.expiresAt < Date.now()) {
|
||||
tokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return issued;
|
||||
}
|
||||
|
||||
export function revokeToken(token: string): void {
|
||||
tokens.delete(token);
|
||||
}
|
||||
|
||||
export function clearAllTokens(): void {
|
||||
tokens.clear();
|
||||
}
|
||||
|
||||
export function pruneExpiredTokens(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [token, issued] of tokens) {
|
||||
if (issued.expiresAt < now) {
|
||||
tokens.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
electron/api/docs-html.ts
Normal file
122
electron/api/docs-html.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
function getScalarBundleCandidates(): string[] {
|
||||
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (processWithResources.resourcesPath) {
|
||||
candidates.push(path.join(processWithResources.resourcesPath, 'scalar', 'api-reference.js'));
|
||||
}
|
||||
|
||||
candidates.push(path.join(process.cwd(), 'node_modules', '@scalar', 'api-reference', 'dist', 'browser', 'standalone.js'));
|
||||
|
||||
try {
|
||||
candidates.push(path.join(path.dirname(require.resolve('@scalar/api-reference')), 'browser', 'standalone.js'));
|
||||
} catch {
|
||||
// ignore; the packaged app path above is the production path
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function getScalarApiReferenceBundlePath(): Promise<string | null> {
|
||||
for (const candidate of getScalarBundleCandidates()) {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDocsHtml(specUrl: string): string {
|
||||
const scalarConfig = {
|
||||
url: specUrl,
|
||||
theme: 'default',
|
||||
layout: 'modern',
|
||||
proxyUrl: '',
|
||||
telemetry: false,
|
||||
persistAuth: false,
|
||||
showDeveloperTools: 'never',
|
||||
hideDownloadButton: false,
|
||||
hideTestRequestButton: false,
|
||||
hideClientButton: false,
|
||||
externalUrls: {
|
||||
dashboardUrl: '',
|
||||
registryUrl: '',
|
||||
proxyUrl: '',
|
||||
apiBaseUrl: ''
|
||||
},
|
||||
agent: {
|
||||
disabled: true,
|
||||
hideAddApi: true
|
||||
},
|
||||
mcp: {
|
||||
disabled: true
|
||||
}
|
||||
};
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self' 'nonce-metoyou-local-api-docs'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
/>
|
||||
<title>MetoYou Local API</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0b0d11;
|
||||
color: #e6e9ee;
|
||||
}
|
||||
.placeholder {
|
||||
max-width: 720px;
|
||||
margin: 8vh auto;
|
||||
padding: 2rem;
|
||||
background: #14181f;
|
||||
border: 1px solid #232a35;
|
||||
border-radius: 12px;
|
||||
}
|
||||
h1 { margin-top: 0; }
|
||||
a { color: #7aa2f7; }
|
||||
code { background: #1f262f; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||
#api-reference { min-height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="api-reference"></div>
|
||||
<noscript>
|
||||
<div class="placeholder">
|
||||
<h1>API Documentation</h1>
|
||||
<p>JavaScript is required to render Scalar. The OpenAPI specification is available directly:</p>
|
||||
<p><a href="${specUrl}">${specUrl}</a></p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script nonce="metoyou-local-api-docs" src="/scalar/api-reference.js"></script>
|
||||
<script nonce="metoyou-local-api-docs">
|
||||
(function () {
|
||||
var config = ${JSON.stringify(scalarConfig)};
|
||||
|
||||
if (!window.Scalar || typeof window.Scalar.createApiReference !== 'function') {
|
||||
var root = document.getElementById('api-reference');
|
||||
root.innerHTML = '<div class="placeholder"><h1>API Documentation</h1>'
|
||||
+ '<p>The bundled Scalar UI could not be loaded.</p>'
|
||||
+ '<p>Spec: <a href="' + config.url + '">' + config.url + '</a></p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
window.Scalar.createApiReference('#api-reference', config);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
107
electron/api/http-helpers.ts
Normal file
107
electron/api/http-helpers.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
export interface RequestContext {
|
||||
method: string;
|
||||
url: URL;
|
||||
pathname: string;
|
||||
headers: IncomingMessage['headers'];
|
||||
remoteAddress: string;
|
||||
bearerToken: string | null;
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
|
||||
|
||||
export function getBearerToken(headers: IncomingMessage['headers']): string | null {
|
||||
const raw = headers.authorization;
|
||||
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (!/^bearer\s+/iu.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = trimmed.replace(/^bearer\s+/iu, '').trim();
|
||||
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
const length = Number(req.headers['content-length'] ?? 0);
|
||||
|
||||
if (length > MAX_BODY_BYTES) {
|
||||
throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE');
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk as string);
|
||||
|
||||
received += buffer.length;
|
||||
|
||||
if (received > MAX_BODY_BYTES) {
|
||||
throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE');
|
||||
}
|
||||
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function sendText(res: ServerResponse, status: number, text: string, contentType = 'text/plain; charset=utf-8'): void {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, message: string, code: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof HttpError) {
|
||||
sendJson(res, error.status, { error: error.message, errorCode: error.code });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
|
||||
sendJson(res, 500, { error: message, errorCode: 'INTERNAL_ERROR' });
|
||||
}
|
||||
8
electron/api/index.ts
Normal file
8
electron/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
applyLocalApiSettings,
|
||||
getLocalApiSnapshot,
|
||||
startLocalApiServer,
|
||||
stopLocalApiServer,
|
||||
type LocalApiSnapshot,
|
||||
type LocalApiStatus
|
||||
} from './local-api-server';
|
||||
276
electron/api/local-api-server.ts
Normal file
276
electron/api/local-api-server.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { createReadStream } from 'fs';
|
||||
import { AddressInfo } from 'net';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { getDataSource } from '../db/database';
|
||||
import { LocalApiSettings, readDesktopSettings } from '../desktop-settings';
|
||||
import { authenticate, matchRoute } from './router';
|
||||
import { clearAllTokens } from './auth-store';
|
||||
import {
|
||||
HttpError,
|
||||
RequestContext,
|
||||
getBearerToken,
|
||||
readJsonBody,
|
||||
sendError,
|
||||
sendJson,
|
||||
sendText
|
||||
} from './http-helpers';
|
||||
|
||||
export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error';
|
||||
|
||||
export interface LocalApiSnapshot {
|
||||
status: LocalApiStatus;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
baseUrl: string | null;
|
||||
error: string | null;
|
||||
exposeOnLan: boolean;
|
||||
scalarEnabled: boolean;
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
let currentStatus: LocalApiStatus = 'stopped';
|
||||
let currentBindHost: string | null = null;
|
||||
let currentBindPort: number | null = null;
|
||||
let currentError: string | null = null;
|
||||
let activeSettings: LocalApiSettings | null = null;
|
||||
|
||||
function pickBindHost(settings: LocalApiSettings): string {
|
||||
return settings.exposeOnLan ? '0.0.0.0' : '127.0.0.1';
|
||||
}
|
||||
|
||||
function buildBaseUrl(host: string, port: number): string {
|
||||
const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
||||
return `http://${safeHost}:${port}`;
|
||||
}
|
||||
|
||||
async function sendFile(res: ServerResponse, status: number, filePath: string, contentType: string): Promise<void> {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
await pipeline(createReadStream(filePath), res);
|
||||
}
|
||||
|
||||
export function getLocalApiSnapshot(): LocalApiSnapshot {
|
||||
const settings = activeSettings ?? readDesktopSettings().localApi;
|
||||
|
||||
return {
|
||||
status: currentStatus,
|
||||
host: currentBindHost,
|
||||
port: currentBindPort,
|
||||
baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null,
|
||||
error: currentError,
|
||||
exposeOnLan: settings.exposeOnLan,
|
||||
scalarEnabled: settings.scalarEnabled
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRequest(req: IncomingMessage, res: ServerResponse, settings: LocalApiSettings): Promise<void> {
|
||||
// CORS for loopback origin only. Local-first; not a public API.
|
||||
const origin = req.headers.origin;
|
||||
const allowOrigin = origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/iu.test(origin) ? origin : 'null';
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', allowOrigin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Access-Control-Max-Age', '600');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let urlObj: URL;
|
||||
|
||||
try {
|
||||
urlObj = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: 'Invalid URL', errorCode: 'INVALID_URL' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestContext: RequestContext = {
|
||||
method: (req.method ?? 'GET').toUpperCase(),
|
||||
url: urlObj,
|
||||
pathname: urlObj.pathname,
|
||||
headers: req.headers,
|
||||
remoteAddress: req.socket.remoteAddress ?? '',
|
||||
bearerToken: getBearerToken(req.headers)
|
||||
};
|
||||
|
||||
const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname);
|
||||
|
||||
if (!match) {
|
||||
if (methodNotAllowed) {
|
||||
sendJson(res, 405, { error: 'Method not allowed', errorCode: 'METHOD_NOT_ALLOWED' });
|
||||
} else {
|
||||
sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.requiresAuth) {
|
||||
const issued = authenticate(requestContext.bearerToken);
|
||||
|
||||
if (!issued) {
|
||||
sendJson(res, 401, { error: 'Authentication required', errorCode: 'UNAUTHORIZED' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dataSource = getDataSource();
|
||||
|
||||
if (!dataSource || !dataSource.isInitialized) {
|
||||
sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' });
|
||||
return;
|
||||
}
|
||||
|
||||
let bodyCache: unknown | undefined;
|
||||
|
||||
try {
|
||||
const baseUrl = buildBaseUrl(currentBindHost ?? '127.0.0.1', currentBindPort ?? settings.port);
|
||||
const result = await match.handler({
|
||||
request: requestContext,
|
||||
settings,
|
||||
baseUrl,
|
||||
dataSource,
|
||||
bodyBuffer: async () => {
|
||||
if (bodyCache === undefined) {
|
||||
bodyCache = await readJsonBody<unknown>(req);
|
||||
}
|
||||
return bodyCache;
|
||||
}
|
||||
});
|
||||
|
||||
if (result.status === 204) {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.filePath) {
|
||||
await sendFile(res, result.status, result.filePath, result.contentType ?? 'application/octet-stream');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.rawBody !== undefined) {
|
||||
sendText(res, result.status, result.rawBody, result.contentType ?? 'text/plain; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, result.status, result.body);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError)) {
|
||||
console.error('[LocalApi] Request handler error:', error);
|
||||
}
|
||||
sendError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StartResult {
|
||||
ok: boolean;
|
||||
snapshot: LocalApiSnapshot;
|
||||
}
|
||||
|
||||
export async function startLocalApiServer(settings: LocalApiSettings): Promise<StartResult> {
|
||||
if (server) {
|
||||
await stopLocalApiServer();
|
||||
}
|
||||
|
||||
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
|
||||
currentStatus = 'starting';
|
||||
currentError = null;
|
||||
currentBindHost = pickBindHost(settings);
|
||||
currentBindPort = settings.port;
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
void handleRequest(req, res, activeSettings!).catch((error) => {
|
||||
console.error('[LocalApi] Unhandled request error:', error);
|
||||
try {
|
||||
sendError(res, error);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return await new Promise<StartResult>((resolve) => {
|
||||
httpServer.once('error', (error) => {
|
||||
currentStatus = 'error';
|
||||
currentError = (error as Error).message;
|
||||
currentBindPort = null;
|
||||
server = null;
|
||||
activeSettings = null;
|
||||
console.error('[LocalApi] Failed to start:', error);
|
||||
resolve({ ok: false, snapshot: getLocalApiSnapshot() });
|
||||
});
|
||||
|
||||
httpServer.listen(settings.port, pickBindHost(settings), () => {
|
||||
const address = httpServer.address() as AddressInfo | null;
|
||||
|
||||
server = httpServer;
|
||||
currentStatus = 'running';
|
||||
currentBindPort = address?.port ?? settings.port;
|
||||
currentError = null;
|
||||
console.log(`[LocalApi] Listening on http://${currentBindHost}:${currentBindPort}`);
|
||||
resolve({ ok: true, snapshot: getLocalApiSnapshot() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopLocalApiServer(): Promise<LocalApiSnapshot> {
|
||||
const httpServer = server;
|
||||
|
||||
if (!httpServer) {
|
||||
currentStatus = 'stopped';
|
||||
currentBindHost = null;
|
||||
currentBindPort = null;
|
||||
activeSettings = null;
|
||||
return getLocalApiSnapshot();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
// close() waits for connections; force-close keep-alives so it returns promptly.
|
||||
httpServer.closeAllConnections?.();
|
||||
});
|
||||
|
||||
server = null;
|
||||
currentStatus = 'stopped';
|
||||
currentBindHost = null;
|
||||
currentBindPort = null;
|
||||
currentError = null;
|
||||
activeSettings = null;
|
||||
clearAllTokens();
|
||||
console.log('[LocalApi] Stopped');
|
||||
return getLocalApiSnapshot();
|
||||
}
|
||||
|
||||
export async function applyLocalApiSettings(): Promise<LocalApiSnapshot> {
|
||||
const settings = readDesktopSettings().localApi;
|
||||
|
||||
if (!settings.enabled) {
|
||||
return await stopLocalApiServer();
|
||||
}
|
||||
|
||||
// If already running with the same bind config, no-op (settings like
|
||||
// scalarEnabled / allowedSignalingServers are read on every request).
|
||||
if (
|
||||
server
|
||||
&& activeSettings
|
||||
&& currentStatus === 'running'
|
||||
&& activeSettings.port === settings.port
|
||||
&& activeSettings.exposeOnLan === settings.exposeOnLan
|
||||
) {
|
||||
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
|
||||
return getLocalApiSnapshot();
|
||||
}
|
||||
|
||||
const result = await startLocalApiServer(settings);
|
||||
|
||||
return result.snapshot;
|
||||
}
|
||||
241
electron/api/openapi.ts
Normal file
241
electron/api/openapi.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
export interface OpenApiBuildOptions {
|
||||
baseUrl: string;
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
||||
const { baseUrl, appVersion } = options;
|
||||
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'MetoYou Local Desktop API',
|
||||
version: appVersion,
|
||||
description:
|
||||
'Authenticated local HTTP API exposed by the MetoYou desktop app. '
|
||||
+ 'Authentication is performed against a configured signaling server. '
|
||||
+ 'Bearer tokens issued here are scoped to this device only.'
|
||||
},
|
||||
servers: [{ url: baseUrl }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'opaque'
|
||||
}
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
required: ['error'],
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
errorCode: { type: 'string' }
|
||||
}
|
||||
},
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password', 'serverUrl'],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
serverUrl: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
description: 'Base URL of the signaling server to authenticate against. Must be in the allowed list configured in the desktop app.'
|
||||
}
|
||||
}
|
||||
},
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
required: ['token', 'expiresAt', 'user'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
expiresAt: { type: 'integer', format: 'int64' },
|
||||
user: { $ref: '#/components/schemas/AuthUser' }
|
||||
}
|
||||
},
|
||||
AuthUser: {
|
||||
type: 'object',
|
||||
required: ['id', 'username', 'displayName'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
displayName: { type: 'string' }
|
||||
}
|
||||
},
|
||||
Profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
displayName: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
avatarUrl: { type: 'string' },
|
||||
status: { type: 'string' }
|
||||
}
|
||||
},
|
||||
Room: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' }
|
||||
},
|
||||
additionalProperties: true
|
||||
},
|
||||
Message: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
roomId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
senderId: { type: 'string' },
|
||||
senderName: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
timestamp: { type: 'integer', format: 'int64' },
|
||||
editedAt: { type: 'integer', format: 'int64' },
|
||||
isDeleted: { type: 'boolean' }
|
||||
},
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [{ bearerAuth: [] }],
|
||||
paths: {
|
||||
'/api/health': {
|
||||
get: {
|
||||
security: [],
|
||||
summary: 'Liveness probe',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Service is alive',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
version: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/openapi.json': {
|
||||
get: {
|
||||
security: [],
|
||||
summary: 'OpenAPI specification',
|
||||
responses: { '200': { description: 'This document' } }
|
||||
}
|
||||
},
|
||||
'/api/auth/login': {
|
||||
post: {
|
||||
security: [],
|
||||
summary: 'Exchange username/password (validated by a signaling server) for a bearer token',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/LoginRequest' }
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Token issued',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/LoginResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'401': {
|
||||
description: 'Invalid credentials',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
|
||||
}
|
||||
},
|
||||
'403': {
|
||||
description: 'Signaling server URL not allowed',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/auth/logout': {
|
||||
post: {
|
||||
summary: 'Revoke the current bearer token',
|
||||
responses: { '204': { description: 'Token revoked' } }
|
||||
}
|
||||
},
|
||||
'/api/profile': {
|
||||
get: {
|
||||
summary: 'Get the current user profile',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Current user profile',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Profile' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'404': {
|
||||
description: 'No current user is set on this device',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/rooms': {
|
||||
get: {
|
||||
summary: 'List rooms (servers) known to this device',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Rooms array',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Room' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/rooms/{roomId}/messages': {
|
||||
get: {
|
||||
summary: 'List messages for a room',
|
||||
parameters: [
|
||||
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } },
|
||||
{ name: 'offset', in: 'query', required: false, schema: { type: 'integer', minimum: 0 } }
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Messages array',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Message' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
294
electron/api/router.ts
Normal file
294
electron/api/router.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user