import * as http from 'http'; import * as https from 'https'; import * as crypto from 'crypto'; import { URL } from 'url'; import { log as loggerLog, error as loggerError } from './logger'; import { isLocalPrivateHost } from './local-https-cert'; interface EncryptionConfig { key: Buffer; iv: Buffer; resourceUrl: string; totalSize: number; mimeType: string; } // Store encryption configs temporarily const encryptionConfigs = new Map(); // Helper: Parse JSON body function parseBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk) => (body += chunk.toString())); req.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } }); req.on('error', reject); }); } // Helper: Send JSON response function sendJSON(res: http.ServerResponse, statusCode: number, data: any) { res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Range', }); res.end(JSON.stringify(data)); } // Helper: Derive CTR counter function deriveCtrCounter(iv: Buffer, blockOffset: bigint): Buffer { const counter = Buffer.from(iv); let carry = blockOffset; for (let i = 15; i >= 0 && carry > 0n; i--) { const sum = BigInt(counter[i]) + (carry & 0xffn); counter[i] = Number(sum & 0xffn); carry = (carry >> 8n) + (sum >> 8n); } return counter; } // Helper: Decrypt chunk function decryptChunk( keyBuffer: Buffer, ivBuffer: Buffer, blockOffset: bigint, encryptedData: Buffer ): Buffer { const counter = deriveCtrCounter(ivBuffer, blockOffset); const decipher = crypto.createDecipheriv('aes-256-ctr', keyBuffer, counter); decipher.setAutoPadding(false); return Buffer.concat([decipher.update(encryptedData), decipher.final()]); } // Helper: Fetch data with range support function fetchRange(url: string, start: number, end: number): Promise { return new Promise((resolve, reject) => { const urlObj = new URL(url); const client = urlObj.protocol === 'https:' && !isLocalPrivateHost(urlObj.hostname) ? https : http; const options: http.RequestOptions = { hostname: urlObj.hostname, port: urlObj.port, path: urlObj.pathname + urlObj.search, method: 'GET', headers: { Range: `bytes=${start}-${end}`, }, }; const req = client.request(options, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { if (res.statusCode === 206 || res.statusCode === 200) { resolve(Buffer.concat(chunks)); } else { reject(new Error(`HTTP ${res.statusCode}`)); } }); res.on('error', reject); }); req.on('error', reject); req.setTimeout(30000, () => { req.destroy(); reject(new Error('Request timeout')); }); req.end(); }); } // Route handlers type RouteHandler = ( req: http.IncomingMessage, res: http.ServerResponse, videoId?: string ) => Promise; const routes: Record = { // POST /api/video/register 'POST /api/video/register': async (req, res) => { try { const body = await parseBody(req); const { videoId, key, iv, resourceUrl, totalSize, mimeType } = body; if (!videoId || !key || !iv || !resourceUrl || !totalSize) { return sendJSON(res, 400, { error: 'Missing required fields: videoId, key, iv, resourceUrl, totalSize', }); } const keyBuffer = Buffer.from(key); const ivBuffer = Buffer.from(iv); if (keyBuffer.length !== 32) { return sendJSON(res, 400, { error: 'Key must be 32 bytes' }); } if (ivBuffer.length !== 16) { return sendJSON(res, 400, { error: 'IV must be 16 bytes' }); } encryptionConfigs.set(videoId, { key: keyBuffer, iv: ivBuffer, resourceUrl, totalSize, mimeType: mimeType || 'video/mp4', }); loggerLog(`[Register] Video registered: ${videoId}`); sendJSON(res, 200, { success: true, videoId }); } catch (error) { loggerError('[Register] Error:', error); sendJSON(res, 500, { error: 'Registration failed' }); } }, // DELETE /api/video/:videoId 'DELETE /api/video': async (req, res, videoId) => { const existed = encryptionConfigs.has(videoId!); encryptionConfigs.delete(videoId!); loggerLog(`[Cleanup] Video unregistered: ${videoId} (existed: ${existed})`); sendJSON(res, 200, { success: true, existed }); }, // GET /api/video/:videoId/stream 'GET /api/video/stream': async (req, res, videoId) => { const config = encryptionConfigs.get(videoId!); if (!config) { loggerError(`[Stream] Video config not found: ${videoId}`); return sendJSON(res, 404, { error: 'Video config not found' }); } const { key, iv, resourceUrl, totalSize, mimeType } = config; const range = req.headers.range; try { let start = 0; let end = totalSize - 1; if (range) { const parts = range.replace(/bytes=/, '').split('-'); start = parseInt(parts[0], 10); end = parts[1] ? parseInt(parts[1], 10) : totalSize - 1; } // Fetch encrypted data const encryptedData = await fetchRange(resourceUrl, start, end); // Decrypt const blockOffset = BigInt(start >> 4); const decryptedData = decryptChunk(key, iv, blockOffset, encryptedData); // Send response const statusCode = range ? 206 : 200; const headers: http.OutgoingHttpHeaders = { 'Content-Type': mimeType, 'Content-Length': decryptedData.length, 'Accept-Ranges': 'bytes', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Range', 'Cache-Control': 'no-cache', }; if (range) { headers['Content-Range'] = `bytes ${start}-${end}/${totalSize}`; } res.writeHead(statusCode, headers); res.end(decryptedData); } catch (error) { loggerError('[Stream] Error:', error); if (!res.headersSent) { sendJSON(res, 500, { error: 'Streaming failed' }); } } }, // GET /api/health 'GET /api/health': async (req, res) => { sendJSON(res, 200, { status: 'ok', activeVideos: encryptionConfigs.size, uptime: process.uptime(), }); }, }; let server: http.Server | null = null; let serverPort: number | null = null; export function startVideoServer(port: number = 57000): Promise { return new Promise((resolve, reject) => { if (server) { loggerLog(`Video server already running on port ${serverPort}`); resolve(serverPort!); return; } // Main request handler server = http.createServer(async (req, res) => { // Handle CORS preflight if (req.method === 'OPTIONS') { res.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Range', }); res.end(); return; } const url = new URL(req.url!, `http://${req.headers.host}`); const pathname = url.pathname; // Route matching if (req.method === 'POST' && pathname === '/api/video/register') { await routes['POST /api/video/register'](req, res); } else if ( req.method === 'DELETE' && pathname.startsWith('/api/video/') ) { const videoId = pathname.split('/')[3]; await routes['DELETE /api/video'](req, res, videoId); } else if (req.method === 'GET' && pathname.includes('/stream')) { const videoId = pathname.split('/')[3]; await routes['GET /api/video/stream'](req, res, videoId); } else if (req.method === 'GET' && pathname === '/api/health') { await routes['GET /api/health'](req, res); } else { sendJSON(res, 404, { error: 'Not found' }); } }); server.on('error', (err: any) => { if (err.code === 'EADDRINUSE') { loggerLog(`Port ${port} in use, trying ${port + 1}`); // Try next port server = null; startVideoServer(port + 1) .then(resolve) .catch(reject); } else { reject(err); } }); server.listen(port, '127.0.0.1', () => { serverPort = port; loggerLog(`Video decryption proxy server running on port ${port}`); loggerLog(`Health check: http://localhost:${port}/api/health`); resolve(port); }); }); } export function stopVideoServer(): Promise { return new Promise((resolve) => { if (!server) { resolve(); return; } server.close(() => { loggerLog('Video server stopped'); server = null; serverPort = null; encryptionConfigs.clear(); resolve(); }); }); } export function getVideoServerPort(): number | null { return serverPort; } export function isVideoServerRunning(): boolean { return server !== null && serverPort !== null; }