Files
xiaobailing-turn-detection/labels/eot-studio/app/api/audio/route.ts
T

110 lines
4.4 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const dataRoot = path.resolve(process.cwd(), "..", "..", "data");
const mimeTypes: Record<string, string> = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".flac": "audio/flac",
".webm": "audio/webm",
};
function hasCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
function within(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
function failure(message: string, status: number, head: boolean): Response {
return new Response(head ? null : message, {
status,
headers: { "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" },
});
}
function parseRange(value: string, size: number): { start: number; end: number } | null {
const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
if (!match || (!match[1] && !match[2]) || size === 0) return null;
if (!match[1]) {
const length = Number(match[2]);
if (!Number.isSafeInteger(length) || length <= 0) return null;
return { start: Math.max(0, size - length), end: size - 1 };
}
const start = Number(match[1]);
const requestedEnd = match[2] ? Number(match[2]) : size - 1;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start >= size || requestedEnd < start) return null;
return { start, end: Math.min(requestedEnd, size - 1) };
}
async function serve(request: Request, head: boolean): Promise<Response> {
const relativePath = new URL(request.url).searchParams.get("path");
if (!relativePath || relativePath.includes("\0") || path.isAbsolute(relativePath)) {
return failure("录音路径无效。", 400, head);
}
const candidate = path.resolve(dataRoot, relativePath);
if (!within(dataRoot, candidate)) return failure("不能访问 data 目录之外的文件。", 403, head);
const extension = path.extname(candidate).toLowerCase();
if (!mimeTypes[extension]) return failure("不支持该录音格式。", 415, head);
let handle;
try {
const [realRoot, realCandidate] = await Promise.all([fs.realpath(dataRoot), fs.realpath(candidate)]);
if (!within(realRoot, realCandidate) || !mimeTypes[path.extname(realCandidate).toLowerCase()]) {
return failure("不能访问 data 目录之外的文件。", 403, head);
}
handle = await fs.open(realCandidate, "r");
const stat = await handle.stat();
if (!stat.isFile()) {
await handle.close();
return failure("录音不存在。", 404, head);
}
const headers = new Headers({
"Accept-Ranges": "bytes",
"Cache-Control": "no-store",
"Content-Type": mimeTypes[path.extname(realCandidate).toLowerCase()],
"X-Content-Type-Options": "nosniff",
});
const rangeHeader = request.headers.get("range");
const range = rangeHeader ? parseRange(rangeHeader, stat.size) : null;
if (rangeHeader && !range) {
await handle.close();
headers.set("Content-Range", `bytes */${stat.size}`);
headers.set("Content-Length", "0");
return new Response(null, { status: 416, headers });
}
const start = range?.start ?? 0;
const end = range?.end ?? stat.size - 1;
const status = range ? 206 : 200;
headers.set("Content-Length", String(Math.max(0, end - start + 1)));
if (range) headers.set("Content-Range", `bytes ${start}-${end}/${stat.size}`);
if (head || stat.size === 0) {
await handle.close();
return new Response(null, { status, headers });
}
const stream = handle.createReadStream({ start, end, autoClose: true });
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, { status, headers });
} catch (error) {
await handle?.close().catch(() => undefined);
if (hasCode(error, "ENOENT") || hasCode(error, "ENOTDIR")) return failure("录音不存在。", 404, head);
console.error("EOT audio request failed", error);
return failure("无法读取录音。", 500, head);
}
}
export async function GET(request: Request): Promise<Response> {
return serve(request, false);
}
export async function HEAD(request: Request): Promise<Response> {
return serve(request, true);
}