mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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%.
264 lines
7.4 KiB
TypeScript
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();
|