// API Proxy Handler - proxies REST requests to desktop apps import { Request, Response, NextFunction } from 'express'; import { ConnectionManager } from '../services/ConnectionManager'; import { SessionManager } from '../services/SessionManager'; import { logger } from '../utils/logger'; export function createApiProxy( connectionManager: ConnectionManager, sessionManager: SessionManager ) { return async (req: Request, res: Response, next: NextFunction) => { const sessionId = req.params.sessionId; // Credentials must be supplied via the header (never the URL/query, // which leaks into logs, history and referrers). const password = req.headers['x-macropad-password'] as string; // Uniform auth failure. A missing session, a missing password, and a // wrong password all return the SAME status + generic message so a // caller cannot infer whether a given session id exists (no // enumeration oracle). validatePassword() safely returns false for an // unknown session id. if (!password || !(await sessionManager.validatePassword(sessionId, password))) { return res.status(401).json({ error: 'Authentication failed' }); } // Only AFTER successful auth do we reveal desktop connectivity, so the // 503 "not connected" signal cannot itself be used to probe which // session ids exist. const desktop = connectionManager.getDesktopBySessionId(sessionId); if (!desktop) { return res.status(503).json({ error: 'Desktop not connected' }); } // Extract the API path (remove the session ID prefix) const apiPath = req.path.replace(`/${sessionId}`, ''); try { const response = await connectionManager.forwardApiRequest( sessionId, req.method, apiPath, req.body, filterHeaders(req.headers) ); // Handle binary responses (images) if (response.body?.base64 && response.body?.contentType) { const buffer = Buffer.from(response.body.base64, 'base64'); res.set('Content-Type', response.body.contentType); // Macro images are content-addressed (uuid filenames) and therefore // immutable, so let the browser cache them aggressively. 'private' // keeps them out of shared proxy caches since they sit behind auth. res.set('Cache-Control', 'private, max-age=31536000, immutable'); res.send(buffer); } else { res.status(response.status).json(response.body); } } catch (error: any) { logger.error(`API proxy error for ${sessionId}:`, error); if (error.message === 'Request timeout') { return res.status(504).json({ error: 'Desktop request timeout' }); } if (error.message === 'Desktop not connected' || error.message === 'Desktop disconnected') { return res.status(503).json({ error: 'Desktop not connected' }); } res.status(500).json({ error: 'Proxy error' }); } }; } function filterHeaders(headers: any): Record { // Only forward relevant headers const allowed = ['content-type', 'accept', 'accept-language']; const filtered: Record = {}; for (const key of allowed) { if (headers[key]) { filtered[key] = headers[key]; } } return filtered; }