From 0f28eda10d07bab17f81256111d8c789609187fb Mon Sep 17 00:00:00 2001 From: lifegpc Date: Thu, 15 Jun 2023 18:05:09 +0800 Subject: [PATCH] Add new route /api/file/[id] --- db.ts | 7 ++ deno.lock | 2 + fresh.gen.ts | 14 ++- import_map.json | 3 +- routes/api/file/[id].ts | 24 ++++ server/get_file_response.ts | 241 ++++++++++++++++++++++++++++++++++++ server/range_parser.ts | 66 ++++++++++ server/range_parser_test.ts | 29 +++++ 8 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 routes/api/file/[id].ts create mode 100644 server/get_file_response.ts create mode 100644 server/range_parser.ts create mode 100644 server/range_parser_test.ts diff --git a/db.ts b/db.ts index 0845b02..b96630c 100644 --- a/db.ts +++ b/db.ts @@ -540,6 +540,13 @@ export class EhDb { if (!this.#dblock) return; eval(`Deno.funlockSync(${this.#dblock.rid});`); } + get_file(id: number) { + const d = this.convert_file(this.db.queryEntries( + "SELECT * FROM file WHERE id = ?;", + [id], + )); + return d.length ? d[0] : null; + } get_files(token: string) { return this.convert_file(this.db.queryEntries( "SELECT * FROM file WHERE token = ?;", diff --git a/deno.lock b/deno.lock index 8e838fb..38c8a16 100644 --- a/deno.lock +++ b/deno.lock @@ -208,6 +208,7 @@ "https://esm.sh/lifegpc-md5@1.0.3": "07fb48e0e08189de5f370ab8a1fa9d4dfd1cdb31a1843fba7e6f30d1c28575cd", "https://esm.sh/lodash@4.17.21/isEqual": "d94a1bba01b5a2061d42ee5c7d27cce9e4c42781b724c48fb9aeb58240272a1b", "https://esm.sh/meilisearch@0.33.0": "f6a40767720d9f1a65e2b9731ec129296b2e974cb6bdb33f0776818dbf777ac1", + "https://esm.sh/mime@3.0.0": "db399b5a45370e2ca58ca7f8484c5fced635ff50d31e805653afd2ad0952317c", "https://esm.sh/preact-material-components@1.6.1/Button": "bc60923d511c6e2e33a7064339b3e643a9c15e3ef232ab063ef570af2ef83dc8", "https://esm.sh/preact-material-components@1.6.1/Checkbox": "bf34f5cd8c6d015916d854d91aab2caf115463e97be9a461f8dd3370ea11a49c", "https://esm.sh/preact-material-components@1.6.1/Dialog": "b0ff8da9c770456748f7e065fecda2fc90f5364ea66cae75ff5f51d57f6a87eb", @@ -449,6 +450,7 @@ "https://esm.sh/v125/meilisearch@0.33.0/dist/types/token.d.ts": "160ef96d5065bc36cd99ce8584f2612ca9fe28ac72d7bf09a37768919d4def18", "https://esm.sh/v125/meilisearch@0.33.0/dist/types/types/index.d.ts": "a0e396a938a5e86b0a838c1f17311c703ff20e21095983a7523549fdb6a2a48f", "https://esm.sh/v125/meilisearch@0.33.0/dist/types/types/types.d.ts": "eae72c3f246fb8aa28089798ca8570d1877c0480d3db311f3cc458ddc0786978", + "https://esm.sh/v125/mime@3.0.0/denonext/mime.mjs": "7fe83d762ec6d1bac50893acc45af4ff52d5ad0176959c4bc48ae0cde77ab50f", "https://raw.githubusercontent.com/lucacasonato/esbuild_deno_loader/8031f71afa1bbcd3237a94b11f53a2e5c5c0e7bf/deps.ts": "b7248e5b750be62613a9417f407e65ed43726d83b11f9631d6dbb58634bbd7d1", "https://raw.githubusercontent.com/lucacasonato/esbuild_deno_loader/8031f71afa1bbcd3237a94b11f53a2e5c5c0e7bf/mod.ts": "3e507379372361162f93325a216b86f6098defb5bb60144555b507bca26d061f", "https://raw.githubusercontent.com/lucacasonato/esbuild_deno_loader/8031f71afa1bbcd3237a94b11f53a2e5c5c0e7bf/src/deno.ts": "71bee6b14e72ca193c0686d8b4f1f47d639a64745b6f5c7576f7a3616f436f57", diff --git a/fresh.gen.ts b/fresh.gen.ts index 5ea8397..dac3b63 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -8,9 +8,10 @@ import * as $1 from "./routes/api/config.ts"; import * as $2 from "./routes/api/deploy_id.ts"; import * as $3 from "./routes/api/exit.ts"; import * as $4 from "./routes/api/export/gallery/zip/[gid].ts"; -import * as $5 from "./routes/api/gallery/[gid].ts"; -import * as $6 from "./routes/api/task.ts"; -import * as $7 from "./routes/index.tsx"; +import * as $5 from "./routes/api/file/[id].ts"; +import * as $6 from "./routes/api/gallery/[gid].ts"; +import * as $7 from "./routes/api/task.ts"; +import * as $8 from "./routes/index.tsx"; import * as $$0 from "./islands/Container.tsx"; import * as $$1 from "./islands/Settings.tsx"; import * as $$2 from "./islands/TaskManager.tsx"; @@ -22,9 +23,10 @@ const manifest = { "./routes/api/deploy_id.ts": $2, "./routes/api/exit.ts": $3, "./routes/api/export/gallery/zip/[gid].ts": $4, - "./routes/api/gallery/[gid].ts": $5, - "./routes/api/task.ts": $6, - "./routes/index.tsx": $7, + "./routes/api/file/[id].ts": $5, + "./routes/api/gallery/[gid].ts": $6, + "./routes/api/task.ts": $7, + "./routes/index.tsx": $8, }, islands: { "./islands/Container.tsx": $$0, diff --git a/import_map.json b/import_map.json index 9231ac1..4246ed5 100644 --- a/import_map.json +++ b/import_map.json @@ -19,6 +19,7 @@ "esbuild/": "https://deno.land/x/esbuild@v0.18.0/", "lifegpc-md5": "https://esm.sh/lifegpc-md5@1.0.3", "meilisearch": "https://esm.sh/meilisearch@0.33.0", - "lodash/": "https://esm.sh/lodash@4.17.21/" + "lodash/": "https://esm.sh/lodash@4.17.21/", + "mime": "https://esm.sh/mime@3.0.0" } } diff --git a/routes/api/file/[id].ts b/routes/api/file/[id].ts new file mode 100644 index 0000000..3963120 --- /dev/null +++ b/routes/api/file/[id].ts @@ -0,0 +1,24 @@ +import { Handlers } from "$fresh/server.ts"; +import { get_task_manager } from "../../../server.ts"; +import { + get_file_response, + GetFileResponseOptions, +} from "../../../server/get_file_response.ts"; + +export const handler: Handlers = { + async GET(req, ctx) { + const id = parseInt(ctx.params.id); + if (isNaN(id)) { + return new Response("Bad Request", { status: 400 }); + } + const m = get_task_manager(); + const f = m.db.get_file(id); + if (!f) { + return new Response("File not found.", { status: 404 }); + } + const opts: GetFileResponseOptions = {}; + const range = req.headers.get("range"); + if (range) opts.range = range; + return await get_file_response(f.path, opts); + }, +}; diff --git a/server/get_file_response.ts b/server/get_file_response.ts new file mode 100644 index 0000000..6eaf599 --- /dev/null +++ b/server/get_file_response.ts @@ -0,0 +1,241 @@ +import { exists } from "std/fs/exists.ts"; +import mime from "mime"; +import { parse_range } from "./range_parser.ts"; + +export type GetFileResponseOptions = { + boundary?: string; + /**@default {4096} */ + chunk?: number; + /**@default {false} */ + combineRange?: boolean; + mimetype?: string; + range?: string; +}; + +export async function get_file_response( + path: string, + opts?: GetFileResponseOptions, +) { + if (!(await exists(path, { isFile: true, isReadable: true }))) { + return new Response("404 not found.", { status: 404 }); + } + const i = await Deno.stat(path); + const mimetype = opts && opts.mimetype ? opts.mimetype : mime.getType(path); + const f = await Deno.open(path); + const close_f = () => { + try { + f.close(); + } catch (_) { + null; + } + }; + const chunk = opts && opts.chunk && opts.chunk > 0 ? opts.chunk : 4096; + try { + if (opts && opts.range) { + const combine = opts.combineRange || false; + const ranges = parse_range(i.size, opts.range, combine); + if (ranges === -1) { + return new Response("Malformed header: range.", { + status: 400, + }); + } else if (ranges === -2 || ranges.type !== "bytes") { + return new Response("Range Not Satisfiable", { status: 416 }); + } + if (ranges.length === 1) { + const start = ranges[0].start; + const end = ranges[0].end + 1; + let now = start; + await f.seek(now, Deno.SeekMode.Start); + const readInto = async (s: Uint8Array) => { + if (now === end) return null; + try { + const n = await f.read(s); + if (n === null) return null; + const readed = Math.min(n, end - now); + now += readed; + return readed; + } catch (e) { + close_f(); + throw e; + } + }; + const readable = new ReadableStream({ + pull: async (c) => { + if (c.byobRequest) { + const r = c.byobRequest; + if (r.view) { + const v = new Uint8Array( + r.view.buffer, + r.view.byteOffset, + ); + const f = await readInto(v); + if (f === null) { + c.close(); + return; + } else if (f !== 0) { + r.respond(f); + return; + } + } else { + const v = new Uint8Array(chunk); + const f = await readInto(v); + if (f === null) { + c.close(); + return; + } else if (f !== 0) { + r.respondWithNewView(v.slice(0, f)); + return; + } + } + } else { + const v = new Uint8Array(chunk); + const f = await readInto(v); + if (f === null) { + c.close(); + return; + } else if (f !== 0) { + c.enqueue(v.slice(0, f)); + return; + } + } + }, + cancel: close_f, + type: "bytes", + }); + const headers: HeadersInit = { + "Content-Length": (end - start).toString(), + "Content-Range": `bytes ${start}-${end - 1}/${i.size}`, + }; + if (mimetype) headers["Content-Type"] = mimetype; + return new Response(readable, { status: 206, headers }); + } else { + let ri = 0; + const boundary = opts.boundary + ? encodeURIComponent(opts.boundary) + : new Date().getTime().toString(); + let current = ranges[ri]; + let now = current.start; + await f.seek(now, Deno.SeekMode.Start); + const encoder = new TextEncoder(); + const boundaries = ranges.map((r) => { + const d = [""]; + const h = new Headers({ + "Content-Range": `bytes ${r.start}-${r.end}/${i.size}`, + }); + if (mimetype) h.append("Content-Type", mimetype); + d.push(`--${boundary}`); + h.forEach((v, k) => { + d.push(`${k}: ${v}`); + }); + d.push(""); + d.push(""); + return encoder.encode(d.join("\r\n")); + }); + boundaries.push(encoder.encode(`\r\n--${boundary}--`)); + const len = boundaries.reduce((p, c) => p + c.length, 0) + + ranges.reduce((p, r) => p + r.end + 1 - r.start, 0); + let current_boundary = boundaries[ri]; + let cb = current_boundary.length; + const readInto = async ( + s: Uint8Array, + ): Promise => { + try { + if (cb > 0) { + const readed = Math.min(s.length, cb); + s.set(current_boundary.slice(0, readed)); + current_boundary.set( + current_boundary.slice(readed), + 0, + ); + cb -= readed; + return readed; + } + if (now === current.end + 1) { + ri++; + if (ri < ranges.length) { + current = ranges[ri]; + now = current.start; + current_boundary = boundaries[ri]; + cb = current_boundary.length; + await f.seek(now, Deno.SeekMode.Start); + return await readInto(s); + } else { + if (ri === ranges.length) { + current_boundary = boundaries[ri]; + cb = current_boundary.length; + return await readInto(s); + } else return null; + } + } + const n = await f.read(s); + if (n === null) return 0; + const readed = Math.min(n, current.end + 1 - now); + now += readed; + return readed; + } catch (e) { + close_f(); + throw e; + } + }; + const readable = new ReadableStream({ + pull: async (c) => { + if (c.byobRequest) { + const r = c.byobRequest; + if (r.view) { + const v = new Uint8Array( + r.view.buffer, + r.view.byteOffset, + ); + const f = await readInto(v); + if (f === null) { + c.close(); + return; + } else if (f !== 0) { + r.respond(f); + return; + } + } else { + const v = new Uint8Array(chunk); + const f = await readInto(v); + if (f === null) { + c.close(); + return; + } else if (f !== 0) { + r.respondWithNewView(v.slice(0, f)); + return; + } + } + } else { + const v = new Uint8Array(chunk); + const f = await readInto(v); + if (f === null) { + c.close(); + return; + } else if (f !== 0) { + c.enqueue(v.slice(0, f)); + return; + } + } + }, + cancel: close_f, + type: "bytes", + }); + const headers: HeadersInit = { + "Content-Length": len.toString(), + "Content-Type": + `multipart/byteranges; boundary=${boundary}`, + }; + return new Response(readable, { status: 206, headers }); + } + } else { + const headers: HeadersInit = { + "Content-Length": i.size.toString(), + }; + if (mimetype) headers["Content-Type"] = mimetype; + return new Response(f.readable, { headers }); + } + } catch (e) { + close_f(); + throw e; + } +} diff --git a/server/range_parser.ts b/server/range_parser.ts new file mode 100644 index 0000000..cfc7fd6 --- /dev/null +++ b/server/range_parser.ts @@ -0,0 +1,66 @@ +export type Range = { + start: number; + end: number; +}; +export type Ranges = Range[] & { type: string }; + +export function parse_range(size: number, str: string, combine = false) { + const index = str.indexOf("="); + if (index === -1) { + return -2; + } + const arr = str.slice(index + 1).split(","); + /**@ts-ignore */ + const ranges = [] as Ranges; + ranges.type = str.slice(0, index); + for (const r of arr) { + const range = r.split("-"); + let start = parseInt(range[0], 10); + let end = parseInt(range[1], 10); + if (isNaN(start)) { + start = size - end; + end = size - 1; + } else if (isNaN(end)) { + end = size - 1; + } + if (end > size - 1) { + end = size - 1; + } + if (isNaN(start) || isNaN(end) || start > end || start < 0) { + continue; + } + ranges.push({ + start: start, + end: end, + }); + } + if (ranges.length < 1) return -1; + return combine ? combineRanges(ranges) : ranges; +} + +function combineRanges(ranges: Ranges) { + const ordered = ranges.map((r, index) => { + return { start: r.start, end: r.end, index }; + }).sort((a, b) => a.start - b.start); + let j = 0; + for (let i = 1; i < ordered.length; i++) { + const range = ordered[i]; + const current = ordered[j]; + if (range.start > current.end + 1) { + ordered[++j] = range; + } else if (range.end > current.end) { + current.end = range.end; + current.index = Math.min(current.index, range.index); + } + } + ordered.length = j + 1; + const combined: Range[] = ordered.sort((a, b) => a.index - b.index).map( + (d) => { + return { start: d.start, end: d.end }; + }, + ); + /**@ts-ignore */ + const r = combined as Ranges; + r.type = ranges.type; + return r; +} diff --git a/server/range_parser_test.ts b/server/range_parser_test.ts new file mode 100644 index 0000000..15e4cc1 --- /dev/null +++ b/server/range_parser_test.ts @@ -0,0 +1,29 @@ +import { assert, assertEquals } from "std/testing/asserts.ts"; +import { parse_range } from "./range_parser.ts"; + +Deno.test("parse_range_test", () => { + let r = parse_range(200, "bytes=1-30, 50-70"); + assert(typeof r !== "number"); + assertEquals(r.type, "bytes"); + assertEquals(r.slice(0), [{ start: 1, end: 30 }, { start: 50, end: 70 }]); + r = parse_range(100, "bytes=20-,50-"); + assert(typeof r !== "number"); + assertEquals(r.type, "bytes"); + assertEquals(r.slice(0), [{ start: 20, end: 99 }, { start: 50, end: 99 }]); + r = parse_range(100, "bytes=-3"); + assert(typeof r !== "number"); + assertEquals(r.type, "bytes"); + assertEquals(r.slice(0), [{ start: 97, end: 99 }]); + r = parse_range(100, "bytes=-10,1-30, 70-96"); + assert(typeof r !== "number"); + assertEquals(r.type, "bytes"); + assertEquals(r.slice(0), [ + { start: 90, end: 99 }, + { start: 1, end: 30 }, + { start: 70, end: 96 }, + ]); + r = parse_range(100, "bytes=-10,1-30,70-96", true); + assert(typeof r !== "number"); + assertEquals(r.type, "bytes"); + assertEquals(r.slice(0), [{ start: 70, end: 99 }, { start: 1, end: 30 }]); +});