Files

327 lines
9.2 KiB
TypeScript

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<string, EncryptionConfig>();
// Helper: Parse JSON body
function parseBody(req: http.IncomingMessage): Promise<any> {
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<Buffer> {
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<void>;
const routes: Record<string, RouteHandler> = {
// 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<number> {
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<void> {
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;
}