mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
- Use videoSourcePath instead of fromFileUrl(videoPath) for cursor telemetry — fixes broken path when videoPath is an HTTP media-server URL - Validate filePath against approvedLocalReadPaths in get-local-media-url IPC handler before returning a URL - Use fs.realpath() instead of path.resolve() to close symlink bypass - Fix range parser to handle suffix ranges (bytes=-500) and guard against NaN values - Add CORS headers (Access-Control-Allow-Origin) to media server responses to prevent canvas tainting when using video frames - Handle OPTIONS preflight requests - Clear stale resolvedWebcamVideoUrl before resolving new URL to prevent flash of stale content
235 lines
6.4 KiB
TypeScript
235 lines
6.4 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";
|
|
|
|
const MEDIA_MIME_TYPES: Record<string, string> = {
|
|
".mp4": "video/mp4",
|
|
".webm": "video/webm",
|
|
".mov": "video/quicktime",
|
|
".mkv": "video/x-matroska",
|
|
".avi": "video/x-msvideo",
|
|
".wav": "audio/wav",
|
|
".mp3": "audio/mpeg",
|
|
".ogg": "audio/ogg",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
};
|
|
|
|
let mediaServerBaseUrl: string | null = null;
|
|
let mediaServerStartPromise: Promise<string> | null = null;
|
|
|
|
function getMediaContentType(filePath: string): string {
|
|
return MEDIA_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
}
|
|
|
|
async function resolveRealPath(filePath: string): Promise<string | null> {
|
|
try {
|
|
return await fs.realpath(path.resolve(filePath));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
|
if (!match || (!match[1] && !match[2])) {
|
|
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
let start: number;
|
|
let end: number;
|
|
|
|
if (!match[1] && match[2]) {
|
|
// Suffix range: bytes=-500
|
|
const suffixLength = Number.parseInt(match[2], 10);
|
|
if (Number.isNaN(suffixLength) || suffixLength <= 0) {
|
|
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
start = Math.max(0, fileSize - suffixLength);
|
|
end = fileSize - 1;
|
|
} else {
|
|
start = Number.parseInt(match[1], 10);
|
|
end = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1;
|
|
}
|
|
|
|
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= fileSize || end >= fileSize) {
|
|
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
if (fileSize === 0) {
|
|
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
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)}`;
|
|
}
|