Files
iptvnator/apps/electron-backend/src/app/server/http-server.ts
4gray 08b868d6c1 test(electron): harden runtime boundary coverage (#1267)
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.

The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.

Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.

Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
2026-07-26 22:27:50 +02:00

264 lines
7.4 KiB
TypeScript

import { app } from 'electron';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
type HttpServerFactory = (requestListener: http.RequestListener) => http.Server;
interface HttpServerOptions {
createServer?: HttpServerFactory;
distPath?: string;
}
/**
* Resolve an HTTP request target within the configured static root.
* The path implementation is injectable so platform-specific semantics remain testable.
*/
export function resolveStaticFilePath(
staticRoot: string,
requestTarget: string,
pathImplementation: path.PlatformPath = path
): string | null {
const separatorIndex = requestTarget.search(/[?#]/);
const encodedPathname =
separatorIndex === -1
? requestTarget
: requestTarget.slice(0, separatorIndex);
let pathname: string;
try {
pathname = decodeURIComponent(encodedPathname);
} catch {
return null;
}
if (pathname.includes('\0')) {
return null;
}
const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html';
const resolvedRoot = pathImplementation.resolve(staticRoot);
const candidate = pathImplementation.resolve(
resolvedRoot,
relativeCandidate
);
if (
candidate === resolvedRoot ||
candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`)
) {
return candidate;
}
return null;
}
/**
* HTTP server for serving the remote control web app and providing REST API endpoints
*/
export class HttpServer {
private readonly createServer: HttpServerFactory;
private server: http.Server | null = null;
private port = 8765;
private isEnabled = false;
private distPath: string | null = null;
private remoteControlHandlers: Map<
string,
(req: http.IncomingMessage, res: http.ServerResponse) => void
> = new Map();
constructor(options: HttpServerOptions = {}) {
this.createServer = options.createServer ?? http.createServer;
this.distPath = options.distPath ?? null;
}
/**
* Get the path to the remote-control-web static files.
* Lazily computed to avoid calling Electron APIs before app is ready.
*/
private getDistPath(): string {
if (this.distPath) {
return this.distPath;
}
// Path to the built remote-control-web app
// In development: use workspace root
// In production: use app path
const appPath = app.getAppPath();
const isDev = !app.isPackaged;
if (isDev) {
// Development mode - use workspace root
this.distPath = path.join(
process.cwd(),
'dist',
'apps',
'remote-control-web',
'browser'
);
} else {
// Production mode - files are bundled with the app
// electron-builder copies remote-control-web/**/* directly to app root
this.distPath = path.join(appPath, 'remote-control-web', 'browser');
}
console.log('[HTTP Server] Serving from:', this.distPath);
return this.distPath;
}
/**
* Start the HTTP server
*/
start(port?: number): void {
if (port !== undefined) {
this.port = port;
}
if (this.server) {
console.log('HTTP server is already running');
return;
}
this.server = this.createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.listen(this.port, () => {
console.log(`HTTP server listening on port ${this.port}`);
console.log(
`Remote control available at: http://localhost:${this.port}`
);
});
this.isEnabled = true;
}
/**
* Stop the HTTP server
*/
stop(): void {
if (!this.server) {
return;
}
this.server.close(() => {
console.log('HTTP server stopped');
});
this.server = null;
this.isEnabled = false;
}
/**
* Update server settings
*/
updateSettings(enabled: boolean, port: number): void {
const needsRestart = this.isEnabled && enabled && this.port !== port;
if (!enabled && this.isEnabled) {
this.stop();
} else if (enabled && !this.isEnabled) {
this.start(port);
} else if (needsRestart) {
this.stop();
this.start(port);
}
}
/**
* Register a handler for remote control API endpoints
*/
registerRemoteControlHandler(
path: string,
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
): void {
this.remoteControlHandlers.set(path, handler);
}
/**
* Handle incoming HTTP requests
*/
private handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
const url = req.url || '/';
// Handle API requests
if (url.startsWith('/api/remote-control/')) {
const handler = this.remoteControlHandlers.get(url);
if (handler) {
handler(req, res);
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Endpoint not found' }));
return;
}
// Serve static files from the remote-control-web app
this.serveStaticFile(url, res);
}
/**
* Serve static files
*/
private serveStaticFile(url: string, res: http.ServerResponse): void {
const distPath = this.getDistPath();
const fullPath = resolveStaticFilePath(distPath, url);
if (!fullPath) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
return;
}
fs.readFile(fullPath, (err, data) => {
if (err) {
// If file not found, try serving index.html (for Angular routing)
if (
err.code === 'ENOENT' &&
fullPath !== path.resolve(distPath, 'index.html')
) {
this.serveStaticFile('/', res);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
return;
}
// Determine content type
const contentType = this.getContentType(fullPath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
}
/**
* Get content type based on file extension
*/
private getContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const contentTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
return contentTypes[ext] || 'application/octet-stream';
}
}
export const httpServer = new HttpServer();