110 lines
4.4 KiB
TypeScript
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);
|
|
}
|