authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-27 20:40:19-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-27 22:30:26-07:00
loga0b0784cd1344ffe9d30881135834a71122fb843
tree2eaabe56585607eeb8a9ac54af1212865bc028b3
parentae4477792c3ad86f158accb18e892e77aa48cf00
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: `@clo/lib/http`


2 files changed, 44 insertions(+), 34 deletions(-)

lib/http.ts created+42
...@@ -0,0 +1,42 @@
1/**
2 * parses the [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests)
3 * header, returning `null` if it is invalid. the `fileSize`
4 */
5export function parseRangeHeader(rangeHeader: string, fileSize: number): Range[] | null {
6 const [unit, ranges] = rangeHeader.split("=");
7 if (!ranges) return null;
8 if (unit !== "bytes") return null;
9
10 const result: Range[] = [];
11 const rangeParts = ranges.split(",");
12
13 for (const range of rangeParts) {
14 const split = range.split("-");
15 const [start, end] = split;
16 if (!start || !end) return null;
17 if (start === "" && end === "") return null;
18 result.push({
19 start: start === "" ? fileSize - +end : +start,
20 end: end === "" ? fileSize - 1 : +end,
21 });
22 }
23
24 // validate that ranges do not intersect
25 result.sort((a, b) => a.start - b.start);
26 for (let i = 1; i < result.length; i++) {
27 if (UNWRAP(result[i]).start <= UNWRAP(result[i - 1]).end) {
28 return null;
29 }
30 }
31
32 return result;
33}
34
35export interface Range {
36 /** inclusive */
37 start: number;
38 /** inclusive */
39 end: number;
40}
41
42import { UNWRAP } from "./assert.ts";
src/file-viewer/backend.tsx+2-34
...@@ -161,7 +161,7 @@ app.get("/file/*", async (c, next) => {...@@ -161,7 +161,7 @@ app.get("/file/*", async (c, next) => {
161 // Range requests161 // Range requests
162 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests162 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
163 if (rangeHeader) {163 if (rangeHeader) {
164 const ranges = parseRange(rangeHeader, file.size);164 const ranges = http.parseRangeHeader(rangeHeader, file.size);
165 if (ranges?.[0] && ranges.length === 1) {165 if (ranges?.[0] && ranges.length === 1) {
166 start = ranges[0][0];166 start = ranges[0][0];
167 end = ranges[0][1];167 end = ranges[0][1];
...@@ -216,39 +216,6 @@ function ifRangeOutdated(file: MediaFile, ifRangeHeader: string) {...@@ -216,39 +216,6 @@ function ifRangeOutdated(file: MediaFile, ifRangeHeader: string) {
216 return new Date(ifRangeHeader) < file.date;216 return new Date(ifRangeHeader) < file.date;
217}217}
218218
219/** The end is inclusive */
220type Ranges = Array<[start: number, end: number]>;
221
222function parseRange(rangeHeader: string, fileSize: number): Ranges | null {
223 const [unit, ranges] = rangeHeader.split("=");
224 if (!ranges) return null;
225 if (unit !== "bytes") return null;
226
227 const result: Array<[start: number, end: number]> = [];
228 const rangeParts = ranges.split(",");
229
230 for (const range of rangeParts) {
231 const split = range.split("-");
232 const [start, end] = split;
233 if (!start || !end) return null;
234 if (start === "" && end === "") return null;
235 const parsedRange: [number, number] = [
236 start === "" ? fileSize - +end : +start,
237 end === "" ? fileSize - 1 : +end,
238 ];
239 result.push(parsedRange);
240 }
241
242 // Validate that ranges do not intersect
243 result.sort((a, b) => a[0] - b[0]);
244 for (let i = 1; i < result.length; i++) {
245 if (UNWRAP(result[i])[0] <= UNWRAP(result[i - 1])[1]) {
246 return null;
247 }
248 }
249
250 return result;
251}
252219
253function checkCotyledonCookie(c: Context) {220function checkCotyledonCookie(c: Context) {
254 const cookie = c.req.header("Cookie");221 const cookie = c.req.header("Cookie");
...@@ -317,6 +284,7 @@ import { type Context, Hono } from "hono";...@@ -317,6 +284,7 @@ import { type Context, Hono } from "hono";
317import * as render from "@clo/lib/render";284import * as render from "@clo/lib/render";
318import { etagMatches, serveAsset } from "#sitegen/assets";285import { etagMatches, serveAsset } from "#sitegen/assets";
319import * as view from "#sitegen/view";286import * as view from "#sitegen/view";
287import * as http from "@clo/lib/http";
320import * as mime from "@clo/lib/mime";288import * as mime from "@clo/lib/mime";
321289
322import { getForFile, requireFriendAuth } from "#src/friend-auth.ts";290import { getForFile, requireFriendAuth } from "#src/friend-auth.ts";