Files

238 lines
6.2 KiB
TypeScript

import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { approvedLocalReadPaths } from "./ipc/state";
import { getMediaContentType } from "./mediaTypes";
let mediaServerBaseUrl: string | null = null;
let mediaServerStartPromise: Promise<string> | null = null;
export function resolveHttpByteRange(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/);
if (!match || (!match[1] && !match[2])) {
return null;
}
if (fileSize === 0) {
return null;
}
if (!match[1] && match[2]) {
// Suffix range: bytes=-500
const suffixLength = Number.parseInt(match[2], 10);
if (Number.isNaN(suffixLength) || suffixLength <= 0) {
return null;
}
return {
start: Math.max(0, fileSize - suffixLength),
end: fileSize - 1,
};
}
const start = Number.parseInt(match[1], 10);
if (Number.isNaN(start) || start < 0 || start >= fileSize) {
return null;
}
const requestedEnd = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1;
if (Number.isNaN(requestedEnd) || requestedEnd < start) {
return null;
}
return {
start,
end: Math.min(requestedEnd, fileSize - 1),
};
}
async function resolveRealPath(filePath: string): Promise<string | null> {
try {
return await fs.realpath(path.resolve(filePath));
} catch {
return null;
}
}
export function isAllowedMediaPath(realPath: string): boolean {
return approvedLocalReadPaths.has(realPath);
}
async function handleMediaRequest(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname !== "/video") {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not Found");
return;
}
const rawPath = url.searchParams.get("path");
if (!rawPath) {
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Missing path parameter");
return;
}
const resolvedPath = await resolveRealPath(rawPath);
if (!resolvedPath || !isAllowedMediaPath(resolvedPath)) {
console.warn(`[media-server] Blocked access to unapproved path: ${rawPath}`);
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Forbidden");
return;
}
const stat = await fs.stat(resolvedPath);
if (!stat.isFile()) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not Found");
return;
}
const contentType = getMediaContentType(resolvedPath);
const fileSize = stat.size;
const rangeHeader = request.headers.range;
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "false",
"Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges",
};
if (request.method === "OPTIONS") {
response.writeHead(204, {
...corsHeaders,
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Range",
});
response.end();
return;
}
if (rangeHeader) {
if (fileSize === 0) {
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` });
response.end();
return;
}
const byteRange = resolveHttpByteRange(rangeHeader, fileSize);
if (!byteRange) {
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` });
response.end();
return;
}
const { start, end } = byteRange;
const chunkSize = end - start + 1;
response.writeHead(206, {
...corsHeaders,
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": String(chunkSize),
"Content-Type": contentType,
"Cache-Control": "no-cache",
});
if (request.method === "HEAD") {
response.end();
return;
}
const stream = createReadStream(resolvedPath, { start, end });
stream.pipe(response);
stream.on("error", () => {
if (!response.headersSent) {
response.writeHead(500, { "Content-Type": "text/plain" });
}
response.end();
});
} else {
response.writeHead(200, {
...corsHeaders,
"Accept-Ranges": "bytes",
"Content-Length": String(fileSize),
"Content-Type": contentType,
"Cache-Control": "no-cache",
});
if (request.method === "HEAD") {
response.end();
return;
}
const stream = createReadStream(resolvedPath);
stream.pipe(response);
stream.on("error", () => {
if (!response.headersSent) {
response.writeHead(500, { "Content-Type": "text/plain" });
}
response.end();
});
}
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not Found");
return;
}
console.error("[media-server] Error handling request:", error);
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Internal Server Error");
}
}
export function getMediaServerBaseUrl(): string | null {
return mediaServerBaseUrl;
}
export async function ensureMediaServer(): Promise<string> {
if (mediaServerBaseUrl) {
return mediaServerBaseUrl;
}
if (mediaServerStartPromise) {
return mediaServerStartPromise;
}
mediaServerStartPromise = new Promise((resolve, reject) => {
const server = createServer((request, response) => {
void handleMediaRequest(request, response);
});
server.once("error", (error) => {
reject(error);
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Media server did not expose a TCP address"));
return;
}
mediaServerBaseUrl = `http://127.0.0.1:${address.port}`;
console.log(`[media-server] Listening at ${mediaServerBaseUrl}`);
resolve(mediaServerBaseUrl);
});
});
return mediaServerStartPromise;
}
export function buildMediaUrl(baseUrl: string, filePath: string): string {
const resolved = path.resolve(filePath);
return `${baseUrl}/video?path=${encodeURIComponent(resolved)}`;
}