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
36 changes: 22 additions & 14 deletions src/download/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("probeAndFetchFirstChunk", () => {
}),
);

const p = await probeAndFetchFirstChunk("https://x/ep.mp3", 4);
const p = await probeAndFetchFirstChunk("https://example.com/ep.mp3", 4);

expect(p.supportsRange).toBe(true);
expect(p.totalSize).toBe(10);
Expand All @@ -86,7 +86,7 @@ describe("probeAndFetchFirstChunk", () => {
}),
);

const p = await probeAndFetchFirstChunk("https://x/ep.mp3", 4);
const p = await probeAndFetchFirstChunk("https://example.com/ep.mp3", 4);

expect(p.supportsRange).toBe(true);
// Must NOT adopt the 4-byte partial Content-Length as the total, or the
Expand All @@ -102,15 +102,17 @@ describe("probeAndFetchFirstChunk", () => {
}),
);

const p = await probeAndFetchFirstChunk("https://x/ep.mp3", 4);
const p = await probeAndFetchFirstChunk("https://example.com/ep.mp3", 4);

expect(p.supportsRange).toBe(false);
expect(p.totalSize).toBe(5);
});

it("throws on a non-2xx status", async () => {
requestUrlMock.mockResolvedValue(res(404, []));
await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4)).rejects.toThrow(/HTTP 404/);
await expect(probeAndFetchFirstChunk("https://example.com/ep.mp3", 4)).rejects.toThrow(
/HTTP 404/,
);
});
});

Expand All @@ -122,7 +124,7 @@ describe("writeStreamedFile", () => {
.mockResolvedValueOnce(res(206, [5, 6], { "content-range": "bytes 4-5/6" }));

const total = await writeStreamedFile(
"https://x/ep.mp3",
"https://example.com/ep.mp3",
"out.mp3",
probe({ totalSize: 6 }),
undefined,
Expand All @@ -145,7 +147,7 @@ describe("writeStreamedFile", () => {
const a = setupAdapter();

const total = await writeStreamedFile(
"https://x/ep.mp3",
"https://example.com/ep.mp3",
"out.mp3",
probe({
firstChunk: new Uint8Array([1, 2, 3]).buffer,
Expand All @@ -167,7 +169,7 @@ describe("writeStreamedFile", () => {
requestUrlMock.mockResolvedValueOnce(res(206, [3, 4])).mockResolvedValueOnce(res(206, [5]));

const total = await writeStreamedFile(
"https://x/ep.mp3",
"https://example.com/ep.mp3",
"out.mp3",
probe({ totalSize: null }),
undefined,
Expand All @@ -183,8 +185,14 @@ describe("writeStreamedFile", () => {
requestUrlMock.mockResolvedValueOnce(res(500, []));

await expect(
writeStreamedFile("https://x/ep.mp3", "out.mp3", probe({ totalSize: 6 }), undefined, 2),
).rejects.toThrow(/Range request failed/);
writeStreamedFile(
"https://example.com/ep.mp3",
"out.mp3",
probe({ totalSize: 6 }),
undefined,
2,
),
).rejects.toThrow(/HTTP 500/);
});
});

Expand All @@ -197,7 +205,7 @@ describe("download size cap (resource exhaustion)", () => {
}),
);

await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4, 100)).rejects.toThrow(
await expect(probeAndFetchFirstChunk("https://example.com/ep.mp3", 4, 100)).rejects.toThrow(
/maximum allowed size/,
);
});
Expand All @@ -208,7 +216,7 @@ describe("download size cap (resource exhaustion)", () => {
res(200, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], { "content-type": "audio/mpeg" }),
);

await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4, 5)).rejects.toThrow(
await expect(probeAndFetchFirstChunk("https://example.com/ep.mp3", 4, 5)).rejects.toThrow(
/maximum allowed size/,
);
});
Expand All @@ -221,7 +229,7 @@ describe("download size cap (resource exhaustion)", () => {

await expect(
writeStreamedFile(
"https://x/ep.mp3",
"https://example.com/ep.mp3",
"out.mp3",
probe({ totalSize: null, firstChunk: new Uint8Array([1, 2]).buffer }),
undefined,
Expand All @@ -239,7 +247,7 @@ describe("download size cap (resource exhaustion)", () => {

await expect(
writeStreamedFile(
"https://x/ep.mp3",
"https://example.com/ep.mp3",
"out.mp3",
probe({
firstChunk: new Uint8Array([1, 2, 3, 4, 5, 6]).buffer,
Expand All @@ -260,7 +268,7 @@ describe("SSRF guard", () => {
"http://127.0.0.1:8080/ep.mp3",
"file:///Users/victim/.ssh/id_rsa",
])("probeAndFetchFirstChunk refuses %s without issuing a request", async (url) => {
await expect(probeAndFetchFirstChunk(url, 4)).rejects.toThrow(/Refusing/);
await expect(probeAndFetchFirstChunk(url, 4)).rejects.toThrow(/not allowed/);
expect(requestUrlMock).not.toHaveBeenCalled();
});

Expand Down
60 changes: 37 additions & 23 deletions src/download/streaming.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type DataAdapter, requestUrl } from "obsidian";
import { type DataAdapter } from "obsidian";
import { get } from "svelte/store";
import { plugin } from "../store";
import { assertFetchableUrl } from "../utility/assertFetchableUrl";
import { encodeUrlForRequest } from "../utility/encodeUrlForRequest";
import { enforceMaxPathLength } from "../utility/enforceMaxPathLength";
import { NetworkError, requestWithTimeout } from "../utility/networkRequest";

// ---- Streaming download (issue #113) ---------------------------------------
// Mobile WebViews have a tight per-process memory budget. Buffering a whole
Expand Down Expand Up @@ -36,6 +36,12 @@ export const DOWNLOAD_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB per range request
// turning the unbounded write into a bounded, recoverable failure.
export const MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024; // 2 GiB

// `requestUrl` cannot be cancelled natively, so these are logical per-request
// ceilings enforced by the shared network boundary. Range downloads still get
// a fresh timeout for each bounded chunk.
const RANGE_PROBE_TIMEOUT_MS = 15 * 60_000;
const RANGE_CHUNK_TIMEOUT_MS = 5 * 60_000;

function tooLargeError(maxSize: number): Error {
const maxMb = Math.round(maxSize / (1024 * 1024));
return new Error(`Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`);
Expand Down Expand Up @@ -82,17 +88,20 @@ export async function probeAndFetchFirstChunk(
chunkSize: number = DOWNLOAD_CHUNK_SIZE,
maxSize: number = MAX_DOWNLOAD_SIZE,
): Promise<RangeProbe> {
assertFetchableUrl(url);
const encodedUrl = encodeUrlForRequest(url);
const response = await requestUrl({
url: encodedUrl,
method: "GET",
headers: { Range: `bytes=0-${chunkSize - 1}` },
throw: false,
});

if (response.status !== 200 && response.status !== 206) {
throw new Error(`Could not download episode (HTTP ${response.status}).`);
let response;
try {
response = await requestWithTimeout(url, {
method: "GET",
headers: { Range: `bytes=0-${chunkSize - 1}` },
timeoutMs: RANGE_PROBE_TIMEOUT_MS,
maxResponseBytes: maxSize,
acceptedStatuses: [200, 206],
});
} catch (error) {
if (error instanceof NetworkError && error.code === "response-too-large") {
throw tooLargeError(maxSize);
}
throw error;
}

const contentType = readHeader(response.headers, "content-type") ?? "";
Expand Down Expand Up @@ -166,7 +175,6 @@ export async function writeStreamedFile(
return written;
}

const encodedUrl = encodeUrlForRequest(url);
for (;;) {
if (probe.totalSize !== null && written >= probe.totalSize) break;

Expand All @@ -175,17 +183,23 @@ export async function writeStreamedFile(
? Math.min(written + chunkSize, probe.totalSize) - 1
: written + chunkSize - 1;

const response = await requestUrl({
url: encodedUrl,
method: "GET",
headers: { Range: `bytes=${written}-${rangeEnd}` },
throw: false,
});
let response;
try {
response = await requestWithTimeout(url, {
method: "GET",
headers: { Range: `bytes=${written}-${rangeEnd}` },
timeoutMs: RANGE_CHUNK_TIMEOUT_MS,
maxResponseBytes: maxSize - written,
acceptedStatuses: [206, 416],
});
} catch (error) {
if (error instanceof NetworkError && error.code === "response-too-large") {
throw tooLargeError(maxSize);
}
throw error;
}

if (response.status === 416) break; // requested past end of file
if (response.status !== 206) {
throw new Error(`Range request failed (HTTP ${response.status}) at byte ${written}.`);
}

const chunk = response.arrayBuffer;
if (chunk.byteLength === 0) break;
Expand Down
Loading