Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions app/api/media/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { parseHttpByteRange } from "@/lib/http-range";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -34,12 +35,9 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ path: strin
const range = req.headers.get("range");

if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range);
let start = m && m[1] ? parseInt(m[1], 10) : 0;
let end = m && m[2] ? parseInt(m[2], 10) : size - 1;
if (isNaN(start) || start < 0) start = 0;
if (isNaN(end) || end >= size) end = size - 1;
if (start > end) return new Response("range not satisfiable", { status: 416, headers: { "content-range": `bytes */${size}` } });
const parsed = parseHttpByteRange(range, size);
if (!parsed) return new Response("range not satisfiable", { status: 416, headers: { "content-range": `bytes */${size}` } });
const { start, end } = parsed;
const nodeStream = fs.createReadStream(full, { start, end });
return new Response(Readable.toWeb(nodeStream) as unknown as ReadableStream, {
status: 206,
Expand Down
11 changes: 5 additions & 6 deletions app/api/media/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { Readable } from "node:stream";
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
import { accountOwnsDomain, getMedia, deleteMedia } from "@/lib/db";
import { parseHttpByteRange } from "@/lib/http-range";
import { mediaSize, mediaStream, deleteMediaFile, thumbFilename } from "@/lib/media";

export const runtime = "nodejs";
Expand All @@ -23,17 +24,15 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
};

const range = req.headers.get("range");
const m2 = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
if (m2) {
let start = m2[1] ? parseInt(m2[1], 10) : 0;
let end = m2[2] ? parseInt(m2[2], 10) : size - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) {
if (range) {
const parsed = parseHttpByteRange(range, size);
if (!parsed) {
return new NextResponse(null, {
status: 416,
headers: { "content-range": `bytes */${size}`, "accept-ranges": "bytes" },
});
}
end = Math.min(end, size - 1);
const { start, end } = parsed;
const stream = Readable.toWeb(mediaStream(m.filename, start, end)) as ReadableStream;
return new NextResponse(stream, {
status: 206,
Expand Down
29 changes: 29 additions & 0 deletions lib/http-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type HttpByteRange = {
start: number;
end: number;
};

export function parseHttpByteRange(range: string, size: number): HttpByteRange | null {
if (!Number.isSafeInteger(size) || size <= 0) return null;

const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
if (!match) return null;

const [, startText, endText] = match;
if (!startText && !endText) return null;

if (!startText) {
const suffixLength = Number(endText);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
const length = Math.min(suffixLength, size);
return { start: size - length, end: size - 1 };
}

const start = Number(startText);
if (!Number.isSafeInteger(start) || start < 0 || start >= size) return null;

const end = endText ? Number(endText) : size - 1;
if (!Number.isSafeInteger(end) || end < start) return null;

return { start, end: Math.min(end, size - 1) };
}
23 changes: 23 additions & 0 deletions tests/http-range.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";

import { parseHttpByteRange } from "../lib/http-range.ts";

test("HTTP range parser handles bounded and open-ended byte ranges", () => {
assert.deepEqual(parseHttpByteRange("bytes=2-5", 10), { start: 2, end: 5 });
assert.deepEqual(parseHttpByteRange("bytes=7-", 10), { start: 7, end: 9 });
assert.deepEqual(parseHttpByteRange("bytes=2-99", 10), { start: 2, end: 9 });
});

test("HTTP range parser handles suffix byte ranges", () => {
assert.deepEqual(parseHttpByteRange("bytes=-4", 10), { start: 6, end: 9 });
assert.deepEqual(parseHttpByteRange("bytes=-99", 10), { start: 0, end: 9 });
});

test("HTTP range parser rejects malformed or unsatisfiable ranges", () => {
assert.equal(parseHttpByteRange("bytes=-0", 10), null);
assert.equal(parseHttpByteRange("bytes=9-2", 10), null);
assert.equal(parseHttpByteRange("bytes=10-11", 10), null);
assert.equal(parseHttpByteRange("garbage bytes=1-2", 10), null);
assert.equal(parseHttpByteRange("bytes=--", 10), null);
});
Loading