From ba0ecafa52fbbfe3205ea2af4eb5ae69ead92e7e Mon Sep 17 00:00:00 2001 From: Kitson Kelly Date: Fri, 19 Apr 2024 08:34:32 +1000 Subject: [PATCH] refactor: break apart util, move form_data to commons --- application.test.ts | 3 +- application.ts | 8 +- body.ts | 5 +- content_disposition.test.ts | 36 -- content_disposition.ts | 143 ------- context.test.ts | 3 +- deno.json | 1 - deps.ts | 16 +- etag.ts | 3 +- form_data.test.ts | 98 ----- form_data.ts | 292 -------------- helpers.ts | 2 +- http_server_bun.ts | 2 +- http_server_native.test.ts | 2 +- http_server_native.ts | 2 +- http_server_native_request.ts | 2 +- http_server_node.ts | 2 +- middleware/proxy.ts | 2 +- middleware/serve.test.ts | 2 +- request.test.ts | 2 +- response.test.ts | 2 +- response.ts | 10 +- router.ts | 2 +- send.test.ts | 2 +- send.ts | 4 +- util.ts | 427 --------------------- utils/consts.ts | 4 + utils/create_promise_with_resolvers.ts | 20 + utils/decode_component.test.ts | 12 + utils/decode_component.ts | 13 + utils/encode_url.ts | 14 + util.test.ts => utils/resolve_path.test.ts | 23 +- utils/resolve_path.ts | 73 ++++ utils/streams.ts | 166 ++++++++ utils/type_guards.ts | 61 +++ 35 files changed, 403 insertions(+), 1056 deletions(-) delete mode 100644 content_disposition.test.ts delete mode 100644 content_disposition.ts delete mode 100644 form_data.test.ts delete mode 100644 form_data.ts delete mode 100644 util.ts create mode 100644 utils/consts.ts create mode 100644 utils/create_promise_with_resolvers.ts create mode 100644 utils/decode_component.test.ts create mode 100644 utils/decode_component.ts create mode 100644 utils/encode_url.ts rename util.test.ts => utils/resolve_path.test.ts (66%) create mode 100644 utils/resolve_path.ts create mode 100644 utils/streams.ts create mode 100644 utils/type_guards.ts diff --git a/application.test.ts b/application.test.ts index 6f9a5027..52fff649 100644 --- a/application.test.ts +++ b/application.test.ts @@ -28,7 +28,8 @@ import type { ServerRequest, ServeTlsOptions, } from "./types.ts"; -import { createPromiseWithResolvers, isNode } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; let optionsStack: Array = []; let serverClosed = false; diff --git a/application.ts b/application.ts index 9cad4d60..3cde59cf 100644 --- a/application.ts +++ b/application.ts @@ -38,12 +38,8 @@ import type { ServerConstructor, ServerRequest, } from "./types.ts"; -import { - createPromiseWithResolvers, - isBun, - isNetAddr, - isNode, -} from "./util.ts"; +import { isBun, isNetAddr, isNode } from "./utils/type_guards.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; /** Base interface for application listening options. */ export interface ListenOptionsBase { diff --git a/body.ts b/body.ts index 377253eb..27fb87ce 100644 --- a/body.ts +++ b/body.ts @@ -8,8 +8,7 @@ * @module */ -import { createHttpError, matches, Status } from "./deps.ts"; -import { parse } from "./form_data.ts"; +import { createHttpError, matches, parseFormData, Status } from "./deps.ts"; import type { ServerRequest } from "./types.ts"; type JsonReviver = (key: string, value: unknown) => unknown; @@ -125,7 +124,7 @@ export class Body { if (this.#body && this.#headers) { const contentType = this.#headers.get("content-type"); if (contentType) { - return parse(contentType, this.#body); + return parseFormData(contentType, this.#body); } } throw createHttpError(Status.BadRequest, "Missing content type."); diff --git a/content_disposition.test.ts b/content_disposition.test.ts deleted file mode 100644 index 95293e80..00000000 --- a/content_disposition.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. - -import { getFilename } from "./content_disposition.ts"; -import { assertEquals } from "./test_deps.ts"; - -const tests: [string, string][] = [ - ['filename="file.ext"', "file.ext"], - ['attachment; filename="file.ext"', "file.ext"], - ['attachment; filename="file.ext"; dummy', "file.ext"], - ["attachment", ""], - ["attachement; filename*=UTF-8'en-US'hello.txt", "hello.txt"], - ['attachement; filename*0="hello"; filename*1="world.txt"', "helloworld.txt"], - ['attachment; filename="A.ext"; filename*="B.ext"', "B.ext"], - [ - 'attachment; filename*="A.ext"; filename*0="B"; filename*1="B.ext"', - "A.ext", - ], - ["attachment; filename=\xe5\x9c\x8b.pdf", "\u570b.pdf"], - ["attachment; filename=okre\x9clenia.rtf", "okreœlenia.rtf"], - ["attachment; filename*=ISO-8859-1''%c3%a4", "\u00c3\u00a4"], - [ - "attachment; filename*0*=ISO-8859-15''euro-sign%3d%a4; filename*=ISO-8859-1''currency-sign%3d%a4", - "currency-sign=\u00a4", - ], - ['INLINE; FILENAME*= "an example.html"', "an example.html"], -]; - -for (const [fixture, expected] of tests) { - Deno.test({ - name: `content_disposition - getFilename("${fixture}")`, - fn() { - const actual = getFilename(fixture); - assertEquals(actual, expected); - }, - }); -} diff --git a/content_disposition.ts b/content_disposition.ts deleted file mode 100644 index a54355d3..00000000 --- a/content_disposition.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Adapted directly from content-disposition.js at - * https://github.com/Rob--W/open-in-browser/blob/master/extension/content-disposition.js - * which is licensed as: - * - * (c) 2017 Rob Wu (https://robwu.nl) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { toParamRegExp, unquote } from "./headers.ts"; - -let needsEncodingFixup = false; - -function fixupEncoding(value: string): string { - if (needsEncodingFixup && /[\x80-\xff]/.test(value)) { - value = textDecode("utf-8", value); - if (needsEncodingFixup) { - value = textDecode("iso-8859-1", value); - } - } - return value; -} - -const FILENAME_STAR_REGEX = toParamRegExp("filename\\*", "i"); -const FILENAME_START_ITER_REGEX = toParamRegExp( - "filename\\*((?!0\\d)\\d+)(\\*?)", - "ig", -); -const FILENAME_REGEX = toParamRegExp("filename", "i"); - -function rfc2047decode(value: string): string { - // deno-lint-ignore no-control-regex - if (!value.startsWith("=?") || /[\x00-\x19\x80-\xff]/.test(value)) { - return value; - } - return value.replace( - /=\?([\w-]*)\?([QqBb])\?((?:[^?]|\?(?!=))*)\?=/g, - (_: string, charset: string, encoding: string, text: string) => { - if (encoding === "q" || encoding === "Q") { - text = text.replace(/_/g, " "); - text = text.replace( - /=([0-9a-fA-F]{2})/g, - (_, hex) => String.fromCharCode(parseInt(hex, 16)), - ); - return textDecode(charset, text); - } - try { - text = atob(text); - // deno-lint-ignore no-empty - } catch {} - return textDecode(charset, text); - }, - ); -} - -function rfc2231getParam(header: string): string { - const matches: [string, string][] = []; - let match: RegExpExecArray | null; - while ((match = FILENAME_START_ITER_REGEX.exec(header))) { - const [, ns, quote, part] = match; - const n = parseInt(ns, 10); - if (n in matches) { - if (n === 0) { - break; - } - continue; - } - matches[n] = [quote, part]; - } - const parts: string[] = []; - for (let n = 0; n < matches.length; ++n) { - if (!(n in matches)) { - break; - } - let [quote, part] = matches[n]; - part = unquote(part); - if (quote) { - part = unescape(part); - if (n === 0) { - part = rfc5987decode(part); - } - } - parts.push(part); - } - return parts.join(""); -} - -function rfc5987decode(value: string): string { - const encodingEnd = value.indexOf(`'`); - if (encodingEnd === -1) { - return value; - } - const encoding = value.slice(0, encodingEnd); - const langValue = value.slice(encodingEnd + 1); - return textDecode(encoding, langValue.replace(/^[^']*'/, "")); -} - -function textDecode(encoding: string, value: string): string { - if (encoding) { - try { - const decoder = new TextDecoder(encoding, { fatal: true }); - const bytes = Array.from(value, (c) => c.charCodeAt(0)); - if (bytes.every((code) => code <= 0xFF)) { - value = decoder.decode(new Uint8Array(bytes)); - needsEncodingFixup = false; - } - // deno-lint-ignore no-empty - } catch {} - } - return value; -} - -export function getFilename(header: string): string { - needsEncodingFixup = true; - - // filename*=ext-value ("ext-value" from RFC 5987, referenced by RFC 6266). - let matches = FILENAME_STAR_REGEX.exec(header); - if (matches) { - const [, filename] = matches; - return fixupEncoding( - rfc2047decode(rfc5987decode(unescape(unquote(filename)))), - ); - } - - // Continuations (RFC 2231 section 3, referenced by RFC 5987 section 3.1). - // filename*n*=part - // filename*n=part - const filename = rfc2231getParam(header); - if (filename) { - return fixupEncoding(rfc2047decode(filename)); - } - - // filename=value (RFC 5987, section 4.1). - matches = FILENAME_REGEX.exec(header); - if (matches) { - const [, filename] = matches; - return fixupEncoding(rfc2047decode(unquote(filename))); - } - - return ""; -} diff --git a/context.test.ts b/context.test.ts index 7bbafeb7..dafaad83 100644 --- a/context.test.ts +++ b/context.test.ts @@ -12,7 +12,8 @@ import { Request as OakRequest } from "./request.ts"; import { Response as OakResponse } from "./response.ts"; import { cloneState } from "./structured_clone.ts"; import type { UpgradeWebSocketFn, UpgradeWebSocketOptions } from "./types.ts"; -import { createPromiseWithResolvers, isNode } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; function createMockApp>( state = {} as S, diff --git a/deno.json b/deno.json index 6b1cf2a0..e4dd6c03 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,6 @@ "./body": "./body.ts", "./context": "./context.ts", "./etag": "./etag.ts", - "./form_data": "./form_data.ts", "./helpers": "./helpers.ts", "./http_server_bun": "./http_server_bun.ts", "./http_server_native": "./http_server_native.ts", diff --git a/deps.ts b/deps.ts index 84de879d..6a074745 100644 --- a/deps.ts +++ b/deps.ts @@ -9,7 +9,6 @@ export { concat } from "jsr:@std/bytes@0.222/concat"; export { copy as copyBytes } from "jsr:@std/bytes@0.222/copy"; export { timingSafeEqual } from "jsr:@std/crypto@0.222/timing-safe-equal"; export { KeyStack } from "jsr:@std/crypto@0.222/unstable-keystack"; -export { encodeBase64 } from "jsr:@std/encoding@0.222/base64"; export { calculate, type ETagOptions, @@ -44,28 +43,29 @@ export { SecureCookieMap, type SecureCookieMapGetOptions, type SecureCookieMapSetDeleteOptions, -} from "jsr:@oak/commons@0.9/cookie_map"; +} from "jsr:@oak/commons@0.10/cookie_map"; +export { parse as parseFormData } from "jsr:@oak/commons@0.10/form_data"; export { createHttpError, errors, HttpError, type HttpErrorOptions, isHttpError, -} from "jsr:@oak/commons@0.9/http_errors"; -export { matches } from "jsr:@oak/commons@0.9/media_types"; -export { type HttpMethod as HTTPMethods } from "jsr:@oak/commons@0.9/method"; +} from "jsr:@oak/commons@0.10/http_errors"; +export { matches } from "jsr:@oak/commons@0.10/media_types"; +export { type HttpMethod as HTTPMethods } from "jsr:@oak/commons@0.10/method"; export { type ByteRange, range, responseRange, -} from "jsr:@oak/commons@0.9/range"; +} from "jsr:@oak/commons@0.10/range"; export { ServerSentEvent, type ServerSentEventInit, ServerSentEventStreamTarget, type ServerSentEventTarget, type ServerSentEventTargetOptions, -} from "jsr:@oak/commons@0.9/server_sent_event"; +} from "jsr:@oak/commons@0.10/server_sent_event"; export { type ErrorStatus, isErrorStatus, @@ -73,7 +73,7 @@ export { type RedirectStatus, Status, STATUS_TEXT, -} from "jsr:@oak/commons@0.9/status"; +} from "jsr:@oak/commons@0.10/status"; export { compile, diff --git a/etag.ts b/etag.ts index 08d6fa1d..0dafce0c 100644 --- a/etag.ts +++ b/etag.ts @@ -10,7 +10,8 @@ import type { State } from "./application.ts"; import type { Context } from "./context.ts"; import { calculate, type ETagOptions } from "./deps.ts"; import type { Middleware } from "./middleware.ts"; -import { BODY_TYPES, isAsyncIterable, isReader } from "./util.ts"; +import { BODY_TYPES } from "./utils/consts.ts"; +import { isAsyncIterable, isReader } from "./utils/type_guards.ts"; // re-exports to maintain backwards compatibility export { diff --git a/form_data.test.ts b/form_data.test.ts deleted file mode 100644 index 05ad2f2d..00000000 --- a/form_data.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { parse } from "./form_data.ts"; -import { assertEquals } from "./test_deps.ts"; - -const FIXTURE_CONTENT_TYPE = - `multipart/form-data; boundary=OAK-SERVER-BOUNDARY`; -const FIXTURE_BODY = - '--OAK-SERVER-BOUNDARY\r\nContent-Disposition: form-data; name="id"\r\n\r\n555\r\n--OAK-SERVER-BOUNDARY\r\nContent-Disposition: form-data; name="title"\r\n\r\nHello\nWorld\n\r\n--OAK-SERVER-BOUNDARY\r\nContent-Disposition: form-data; name="fileb"; filename="mod2.ts"\r\nContent-Type: video/mp2t\r\n\r\nconsole.log("Hello world");\n\r\n--OAK-SERVER-BOUNDARY--\r\n'; - -const FIXTURE_BODY_NO_FIELDS = `--OAK-SERVER-BOUNDARY--\r\n`; - -const FIXTURE_BODY_UTF8_FILENAME = - `--OAK-SERVER-BOUNDARY\r\nContent-Disposition: form-data; name="id"\r\n\r\n555\r\n--OAK-SERVER-BOUNDARY\r\nContent-Disposition: form-data; name="filea"; filename="编写软件很难.ts"\r\nContent-Type: video/mp2t\r\n\r\nexport { printHello } from "./print_hello.ts";\n\r\n--OAK-SERVER-BOUNDARY--\r\n`; - -const FIXTURE_BODY_NO_NEWLINE = - `--OAK-SERVER-BOUNDARY\r\nContent-Disposition: form-data; name="noNewline"; filename="noNewline.txt"\r\nContent-Type: text/plain\r\n\r\n555\r\n--OAK-SERVER-BOUNDARY--\r\n`; - -function assertIsFile(value: unknown): asserts value is File { - if ( - !(value && typeof value === "object" && "size" in value && - "type" in value && "name" in value) - ) { - throw new Error("Value is not a File"); - } -} - -Deno.test({ - name: "form_data - parse() - basic", - async fn() { - const req = new Request("http://localhost:8080", { - body: FIXTURE_BODY, - method: "POST", - headers: { "content-type": FIXTURE_CONTENT_TYPE }, - }); - const formData = await parse(req.headers.get("content-type")!, req.body!); - assertEquals([...formData].length, 3, "length should be 3"); - assertEquals(formData.get("id"), "555", "id should be '555'"); - assertEquals( - formData.get("title"), - "Hello\nWorld\n", - "title should be 'Hello World'", - ); - const fileb = formData.get("fileb"); - assertIsFile(fileb); - assertEquals(fileb.type, "video/mp2t", "should be of type 'video/mp2t'"); - assertEquals(fileb.name, "mod2.ts", "filename should be 'mod2.ts'"); - assertEquals( - await fileb.text(), - `console.log("Hello world");\n`, - "file contents should match", - ); - }, -}); - -Deno.test({ - name: "form_data - parse() - no fields", - async fn() { - const req = new Request("http://localhost:8080", { - body: FIXTURE_BODY_NO_FIELDS, - method: "POST", - headers: { "content-type": FIXTURE_CONTENT_TYPE }, - }); - const formData = await parse(req.headers.get("content-type")!, req.body!); - assertEquals([...formData].length, 0); - }, -}); - -Deno.test({ - name: "form_data - parse() - mbc file name", - async fn() { - const req = new Request("http://localhost:8080", { - body: FIXTURE_BODY_UTF8_FILENAME, - method: "POST", - headers: { "content-type": FIXTURE_CONTENT_TYPE }, - }); - const formData = await parse(req.headers.get("content-type")!, req.body!); - assertEquals([...formData].length, 2); - const filea = formData.get("filea"); - assertIsFile(filea); - assertEquals(filea.type, "video/mp2t"); - assertEquals(filea.name, "编写软件很难.ts"); - }, -}); - -Deno.test({ - name: "for_data - parse() - no new line", - async fn() { - const req = new Request("http://localhost:8080", { - body: FIXTURE_BODY_NO_NEWLINE, - method: "POST", - headers: { "content-type": FIXTURE_CONTENT_TYPE }, - }); - const formData = await parse(req.headers.get("content-type")!, req.body!); - assertEquals([...formData].length, 1); - const noNewline = formData.get("noNewline"); - assertIsFile(noNewline); - assertEquals(await noNewline.text(), "555"); - }, -}); diff --git a/form_data.ts b/form_data.ts deleted file mode 100644 index 6f0efbb8..00000000 --- a/form_data.ts +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. - -/** The ability to parse a request body into {@linkcode FormData} when not - * supported natively by the runtime. - * - * @module - */ - -import { concat, createHttpError, Status, timingSafeEqual } from "./deps.ts"; -import { toParamRegExp, unquote } from "./headers.ts"; -import { skipLWSPChar, stripEol } from "./util.ts"; -import { getFilename } from "./content_disposition.ts"; - -import "./util.ts"; - -type Part = [ - key: string, - value: string | Blob, - fileName: string | undefined, -]; - -const BOUNDARY_PARAM_RE = toParamRegExp("boundary", "i"); -const NAME_PARAM_RE = toParamRegExp("name", "i"); - -const LF = 0x0a; -const CR = 0x0d; -const COLON = 0x3a; -const HTAB = 0x09; -const SPACE = 0x20; - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -function indexOfCRLF(u8: Uint8Array): number { - let start = 0; - while (true) { - const idx = u8.indexOf(CR, start); - if (idx < 0) { - return idx; - } - if (u8.at(idx + 1) === LF) { - return idx + 1; - } - start = idx + 1; - } -} - -function isEqual(a: Uint8Array, b: Uint8Array): boolean { - return timingSafeEqual(skipLWSPChar(a), b); -} - -class MultipartStream extends TransformStream { - #buffer = new Uint8Array(0); - #boundaryFinal: Uint8Array; - #boundaryPart: Uint8Array; - #current?: { headers?: Headers }; - #finalized = false; - #pos = 0; - - constructor(contentType: string) { - const matches = contentType.match(BOUNDARY_PARAM_RE); - if (!matches) { - throw createHttpError( - Status.BadRequest, - `Content type "${contentType}" does not contain a valid boundary.`, - ); - } - super({ - transform: (chunk, controller) => { - this.#transform(chunk, controller); - }, - flush: (controller) => { - if (!this.#finalized) { - controller.error( - createHttpError( - Status.BadRequest, - `Body terminated without being finalized.`, - ), - ); - } - }, - }); - - let [, boundary] = matches; - boundary = unquote(boundary); - this.#boundaryPart = encoder.encode(`--${boundary}`); - this.#boundaryFinal = encoder.encode(`--${boundary}--`); - } - - #readLine(strip = true): Uint8Array | null { - let slice: Uint8Array | null = null; - - const i = indexOfCRLF(this.#buffer.subarray(this.#pos)); - if (i >= 0) { - slice = this.#buffer.subarray(this.#pos, this.#pos + i + 1); - this.#pos += i + 1; - if (slice.byteLength && strip) { - return stripEol(slice); - } - return slice; - } - return null; - } - - #readHeaders(): Headers | null { - const currentPos = this.#pos; - const headers = new Headers(); - let line = this.#readLine(); - while (line) { - let i = line.indexOf(COLON); - if (i < 0) { - return headers; - } - const key = decoder.decode(line.subarray(0, i)).trim(); - i++; - while (i < line.byteLength && (line[i] === SPACE || line[i] === HTAB)) { - i++; - } - const value = decoder.decode(line.subarray(i)).trim(); - headers.set(key, encodeURIComponent(value)); - line = this.#readLine(); - } - // if we have a partial part that breaks across chunks, we won't have the - // right read position and so need to reset the pos and return a `null`. - this.#pos = currentPos; - return null; - } - - *#readParts(): IterableIterator { - while (true) { - const headers = this.#current?.headers ?? this.#readHeaders(); - if (!headers) { - break; - } - const contentDisposition = decodeURIComponent( - headers.get("content-disposition") ?? "", - ); - if (!contentDisposition) { - throw createHttpError( - Status.BadRequest, - 'Form data part missing "content-disposition" header.', - ); - } - if (!contentDisposition.match(/^form-data;/i)) { - throw createHttpError( - Status.BadRequest, - `Invalid "content-disposition" header: "${contentDisposition}"`, - ); - } - const matches = NAME_PARAM_RE.exec(contentDisposition); - if (!matches) { - throw createHttpError( - Status.BadRequest, - "Unable to determine name of form body part.", - ); - } - this.#current = { headers }; - let [, key] = matches; - key = unquote(key); - const rawContentType = headers.get("content-type"); - if (rawContentType) { - const contentType = decodeURIComponent(rawContentType); - const fileName = getFilename(contentDisposition); - const arrays: Uint8Array[] = []; - const pos = this.#pos; - while (true) { - const line = this.#readLine(false); - if (!line) { - // abnormal termination of part, therefore we will reset pos and - // return - this.#pos = pos; - return null; - } - const stripped = stripEol(line); - if ( - isEqual(stripped, this.#boundaryPart) || - isEqual(stripped, this.#boundaryFinal) - ) { - this.#current = {}; - arrays[arrays.length - 1] = stripEol(arrays[arrays.length - 1]); - yield [key, new Blob(arrays, { type: contentType }), fileName]; - this.#truncate(); - if (isEqual(stripped, this.#boundaryFinal)) { - this.#finalized = true; - return null; - } - break; - } - arrays.push(line); - } - } else { - const lines: string[] = []; - const pos = this.#pos; - while (true) { - const line = this.#readLine(); - if (!line) { - this.#pos = pos; - return null; - } - if ( - isEqual(line, this.#boundaryPart) || - isEqual(line, this.#boundaryFinal) - ) { - this.#current = {}; - yield [key, lines.join("\n"), undefined]; - this.#truncate(); - if (isEqual(line, this.#boundaryFinal)) { - this.#finalized = true; - return null; - } - break; - } - lines.push(decoder.decode(line)); - } - } - } - return null; - } - - #readToBoundary(): "part" | "final" | null { - let line: Uint8Array | null; - while ((line = this.#readLine())) { - if (isEqual(line, this.#boundaryPart)) { - return "part"; - } - if (isEqual(line, this.#boundaryFinal)) { - return "final"; - } - } - return null; - } - - #transform( - chunk: Uint8Array, - controller: TransformStreamDefaultController, - ) { - this.#buffer = concat([this.#buffer, chunk]); - if (!this.#current) { - const boundary = this.#readToBoundary(); - if (!boundary) { - return; - } - if (boundary === "final") { - this.#finalized = true; - controller.terminate(); - return; - } - } - try { - for (const part of this.#readParts()) { - if (!part) { - break; - } - controller.enqueue(part); - } - if (this.#finalized) { - controller.terminate(); - } - } catch (err) { - controller.error(err); - } - } - - #truncate() { - this.#buffer = this.#buffer.slice(this.#pos); - this.#pos = 0; - } -} - -/** Take a content type and the body of a request and parse it as - * {@linkcode FormData}. - * - * This is used in run-times where there isn't native support for this - * feature. */ -export async function parse( - contentType: string, - body: ReadableStream, -): Promise { - const formData = new FormData(); - if (body) { - const stream = body - .pipeThrough(new MultipartStream(contentType)); - for await (const [key, value, fileName] of stream) { - if (fileName) { - formData.append(key, value, fileName); - } else { - formData.append(key, value); - } - } - } - return formData; -} diff --git a/helpers.ts b/helpers.ts index 962f6630..5d103047 100644 --- a/helpers.ts +++ b/helpers.ts @@ -8,7 +8,7 @@ import type { Context } from "./context.ts"; import type { RouterContext } from "./router.ts"; -import { isRouterContext } from "./util.ts"; +import { isRouterContext } from "./utils/type_guards.ts"; interface GetQueryOptionsBase { /** The return value should be a `Map` instead of a record object. */ diff --git a/http_server_bun.ts b/http_server_bun.ts index 5d6da1f2..e11c68c2 100644 --- a/http_server_bun.ts +++ b/http_server_bun.ts @@ -14,7 +14,7 @@ import type { ServerRequest, ServeTlsOptions, } from "./types.ts"; -import { createPromiseWithResolvers } from "./util.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; type TypedArray = | Uint8Array diff --git a/http_server_native.test.ts b/http_server_native.test.ts index f635e57d..13bee343 100644 --- a/http_server_native.test.ts +++ b/http_server_native.test.ts @@ -6,7 +6,7 @@ import { Server } from "./http_server_native.ts"; import { NativeRequest } from "./http_server_native_request.ts"; import { Application } from "./application.ts"; -import { isNode } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; function createMockNetAddr(): Deno.NetAddr { return { transport: "tcp", hostname: "remote", port: 4567 }; diff --git a/http_server_native.ts b/http_server_native.ts index e3f0c982..34984060 100644 --- a/http_server_native.ts +++ b/http_server_native.ts @@ -16,7 +16,7 @@ import type { ServeOptions, ServeTlsOptions, } from "./types.ts"; -import { createPromiseWithResolvers } from "./util.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; const serve: | (( diff --git a/http_server_native_request.ts b/http_server_native_request.ts index 61b60f4c..186a2c95 100644 --- a/http_server_native_request.ts +++ b/http_server_native_request.ts @@ -6,7 +6,7 @@ import type { UpgradeWebSocketFn, UpgradeWebSocketOptions, } from "./types.ts"; -import { createPromiseWithResolvers } from "./util.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; // deno-lint-ignore no-explicit-any export const DomResponse: typeof Response = (globalThis as any).Response ?? diff --git a/http_server_node.ts b/http_server_node.ts index d39824b2..08fe9342 100644 --- a/http_server_node.ts +++ b/http_server_node.ts @@ -7,7 +7,7 @@ */ import type { Listener, OakServer, ServerRequest } from "./types.ts"; -import { createPromiseWithResolvers } from "./util.ts"; +import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; // There are quite a few differences between Deno's `std/node/http` and the // typings for Node.js for `"http"`. Since we develop everything in Deno, but diff --git a/middleware/proxy.ts b/middleware/proxy.ts index 0905240d..163a7dc1 100644 --- a/middleware/proxy.ts +++ b/middleware/proxy.ts @@ -15,7 +15,7 @@ import type { RouterContext, RouterMiddleware, } from "../router.ts"; -import { isRouterContext } from "../util.ts"; +import { isRouterContext } from "../utils/type_guards.ts"; type Fetch = ( input: Request, diff --git a/middleware/serve.test.ts b/middleware/serve.test.ts index 0103ed12..0522972f 100644 --- a/middleware/serve.test.ts +++ b/middleware/serve.test.ts @@ -6,7 +6,7 @@ import type { Next } from "../middleware.ts"; import { type RouteParams, Router, type RouterContext } from "../router.ts"; import { assertEquals, assertStrictEquals } from "../test_deps.ts"; import { createMockApp, createMockNext } from "../testing.ts"; -import { isNode } from "../util.ts"; +import { isNode } from "../utils/type_guards.ts"; import { route, serve } from "./serve.ts"; diff --git a/request.test.ts b/request.test.ts index 1d5b891c..6db0e536 100644 --- a/request.test.ts +++ b/request.test.ts @@ -11,7 +11,7 @@ import { import { NativeRequest } from "./http_server_native_request.ts"; import type { NativeRequestInfo } from "./http_server_native_request.ts"; import { Request } from "./request.ts"; -import { isNode } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; function createMockNativeRequest( url = "http://localhost/index.html", diff --git a/response.test.ts b/response.test.ts index ea7b2669..8bca3d46 100644 --- a/response.test.ts +++ b/response.test.ts @@ -4,7 +4,7 @@ import { assert, Status } from "./deps.ts"; import { assertEquals, assertThrows } from "./test_deps.ts"; import type { Request } from "./request.ts"; import { REDIRECT_BACK, Response } from "./response.ts"; -import { isNode } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; function createMockRequest({ headers, diff --git a/response.ts b/response.ts index 7bbda960..37d21560 100644 --- a/response.ts +++ b/response.ts @@ -11,16 +11,14 @@ import { contentType, isRedirectStatus, Status, STATUS_TEXT } from "./deps.ts"; import { DomResponse } from "./http_server_native_request.ts"; import type { Request } from "./request.ts"; +import { isAsyncIterable, isHtml, isReader } from "./utils/type_guards.ts"; +import { BODY_TYPES } from "./utils/consts.ts"; +import { encodeUrl } from "./utils/encode_url.ts"; import { - BODY_TYPES, - encodeUrl, - isAsyncIterable, - isHtml, - isReader, readableStreamFromAsyncIterable, readableStreamFromReader, Uint8ArrayTransformStream, -} from "./util.ts"; +} from "./utils/streams.ts"; /** The various types of bodies supported when setting the value of `.body` * on a {@linkcode Response} */ diff --git a/router.ts b/router.ts index 833c39c3..db338fd2 100644 --- a/router.ts +++ b/router.ts @@ -67,7 +67,7 @@ import { type TokensToRegexpOptions, } from "./deps.ts"; import { compose, type Middleware } from "./middleware.ts"; -import { decodeComponent } from "./util.ts"; +import { decodeComponent } from "./utils/decode_component.ts"; interface Matches { path: Layer[]; diff --git a/send.test.ts b/send.test.ts index babb9c56..48f11273 100644 --- a/send.test.ts +++ b/send.test.ts @@ -13,7 +13,7 @@ import { assert, errors } from "./deps.ts"; import * as etag from "./etag.ts"; import type { RouteParams } from "./router.ts"; import { send } from "./send.ts"; -import { isNode } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; function setup< // deno-lint-ignore no-explicit-any diff --git a/send.ts b/send.ts index 0fb23958..4d118e0b 100644 --- a/send.ts +++ b/send.ts @@ -27,7 +27,9 @@ import { Status, } from "./deps.ts"; import type { Response } from "./response.ts"; -import { decodeComponent, isNode, resolvePath } from "./util.ts"; +import { isNode } from "./utils/type_guards.ts"; +import { decodeComponent } from "./utils/decode_component.ts"; +import { resolvePath } from "./utils/resolve_path.ts"; if (isNode()) { console.warn("oak send() does not work under Node.js."); diff --git a/util.ts b/util.ts deleted file mode 100644 index bb8df6c4..00000000 --- a/util.ts +++ /dev/null @@ -1,427 +0,0 @@ -// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. - -import type { State } from "./application.ts"; -import type { Context } from "./context.ts"; -import { - createHttpError, - encodeBase64, - isAbsolute, - join, - normalize, - SEPARATOR, -} from "./deps.ts"; -import type { RouteParams, RouterContext } from "./router.ts"; -import type { Data, Key, NetAddr } from "./types.ts"; - -import "./node_shims.ts"; - -const ENCODE_CHARS_REGEXP = - /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g; -const HTAB = "\t".charCodeAt(0); -const SPACE = " ".charCodeAt(0); -const CR = "\r".charCodeAt(0); -const LF = "\n".charCodeAt(0); -const UNMATCHED_SURROGATE_PAIR_REGEXP = - /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g; -const UNMATCHED_SURROGATE_PAIR_REPLACE = "$1\uFFFD$2"; -export const DEFAULT_CHUNK_SIZE = 16_640; // 17 Kib - -/** Body types which will be coerced into strings before being sent. */ -export const BODY_TYPES = ["string", "number", "bigint", "boolean", "symbol"]; - -const hasPromiseWithResolvers = "withResolvers" in Promise; - -/** Offloads to the native `Promise.withResolvers` when available. - * - * Currently Node.js does not support it, while Deno does. - */ -export function createPromiseWithResolvers(): { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - // deno-lint-ignore no-explicit-any - reject: (reason?: any) => void; -} { - if (hasPromiseWithResolvers) { - return Promise.withResolvers(); - } - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve: resolve!, reject: reject! }; -} - -/** Safely decode a URI component, where if it fails, instead of throwing, - * just returns the original string - */ -export function decodeComponent(text: string) { - try { - return decodeURIComponent(text); - } catch { - return text; - } -} - -/** Encodes the url preventing double enconding */ -export function encodeUrl(url: string) { - return String(url) - .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) - .replace(ENCODE_CHARS_REGEXP, encodeURI); -} - -function bufferToHex(buffer: ArrayBuffer): string { - const arr = Array.from(new Uint8Array(buffer)); - return arr.map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -export async function getRandomFilename( - prefix = "", - extension = "", -): Promise { - const buffer = await crypto.subtle.digest( - "SHA-1", - crypto.getRandomValues(new Uint8Array(256)), - ); - return `${prefix}${bufferToHex(buffer)}${extension ? `.${extension}` : ""}`; -} - -export async function getBoundary(): Promise { - const buffer = await crypto.subtle.digest( - "SHA-1", - crypto.getRandomValues(new Uint8Array(256)), - ); - return `oak_${bufferToHex(buffer)}`; -} - -/** Guard for Async Iterables */ -export function isAsyncIterable( - value: unknown, -): value is AsyncIterable { - return typeof value === "object" && value !== null && - Symbol.asyncIterator in value && - // deno-lint-ignore no-explicit-any - typeof (value as any)[Symbol.asyncIterator] === "function"; -} - -export function isRouterContext< - R extends string, - P extends RouteParams, - S extends State, ->( - value: Context, -): value is RouterContext { - return "params" in value; -} - -/** Guard for `Deno.Reader`. */ -export function isReader(value: unknown): value is Deno.Reader { - return typeof value === "object" && value !== null && "read" in value && - typeof (value as Record).read === "function"; -} - -function isCloser(value: unknown): value is Deno.Closer { - return typeof value === "object" && value != null && "close" in value && - // deno-lint-ignore no-explicit-any - typeof (value as Record)["close"] === "function"; -} - -export function isNetAddr(value: unknown): value is NetAddr { - return typeof value === "object" && value != null && "transport" in value && - "hostname" in value && "port" in value; -} - -export function isListenTlsOptions( - value: unknown, -): value is Deno.ListenTlsOptions { - return typeof value === "object" && value !== null && - ("cert" in value || "certFile" in value) && - ("key" in value || "keyFile" in value) && "port" in value; -} - -export interface ReadableStreamFromReaderOptions { - /** If the `reader` is also a `Deno.Closer`, automatically close the `reader` - * when `EOF` is encountered, or a read error occurs. - * - * Defaults to `true`. */ - autoClose?: boolean; - - /** The size of chunks to allocate to read, the default is ~16KiB, which is - * the maximum size that Deno operations can currently support. */ - chunkSize?: number; - - /** The queuing strategy to create the `ReadableStream` with. */ - strategy?: { highWaterMark?: number | undefined; size?: undefined }; -} - -/** - * Create a `ReadableStream` from an `AsyncIterable`. - */ -export function readableStreamFromAsyncIterable( - source: AsyncIterable, -): ReadableStream { - return new ReadableStream({ - async start(controller) { - for await (const chunk of source) { - if (BODY_TYPES.includes(typeof chunk)) { - controller.enqueue(encoder.encode(String(chunk))); - } else if (chunk instanceof Uint8Array) { - controller.enqueue(chunk); - } else if (ArrayBuffer.isView(chunk)) { - controller.enqueue(new Uint8Array(chunk.buffer)); - } else if (chunk instanceof ArrayBuffer) { - controller.enqueue(new Uint8Array(chunk)); - } else { - try { - controller.enqueue(encoder.encode(JSON.stringify(chunk))); - } catch { - // we just swallow errors here - } - } - } - controller.close(); - }, - }); -} - -/** - * Create a `ReadableStream` from a `Deno.Reader`. - * - * When the pull algorithm is called on the stream, a chunk from the reader - * will be read. When `null` is returned from the reader, the stream will be - * closed along with the reader (if it is also a `Deno.Closer`). - */ -export function readableStreamFromReader( - reader: Deno.Reader | (Deno.Reader & Deno.Closer), - options: ReadableStreamFromReaderOptions = {}, -): ReadableStream { - const { - autoClose = true, - chunkSize = DEFAULT_CHUNK_SIZE, - strategy, - } = options; - - return new ReadableStream({ - async pull(controller) { - const chunk = new Uint8Array(chunkSize); - try { - const read = await reader.read(chunk); - if (read === null) { - if (isCloser(reader) && autoClose) { - reader.close(); - } - controller.close(); - return; - } - controller.enqueue(chunk.subarray(0, read)); - } catch (e) { - controller.error(e); - if (isCloser(reader)) { - reader.close(); - } - } - }, - cancel() { - if (isCloser(reader) && autoClose) { - reader.close(); - } - }, - }, strategy); -} - -/** Determines if a string "looks" like HTML */ -export function isHtml(value: string): boolean { - return /^\s*<(?:!DOCTYPE|html|body)/i.test(value); -} - -/** Returns `u8` with leading white space removed. */ -export function skipLWSPChar(u8: Uint8Array): Uint8Array { - const result = new Uint8Array(u8.length); - let j = 0; - for (let i = 0; i < u8.length; i++) { - if (u8[i] === SPACE || u8[i] === HTAB) continue; - result[j++] = u8[i]; - } - return result.slice(0, j); -} - -export function stripEol(value: Uint8Array): Uint8Array { - if (value[value.byteLength - 1] == LF) { - let drop = 1; - if (value.byteLength > 1 && value[value.byteLength - 2] === CR) { - drop = 2; - } - return value.subarray(0, value.byteLength - drop); - } - return value; -} - -/*! - * Adapted directly from https://github.com/pillarjs/resolve-path - * which is licensed as follows: - * - * The MIT License (MIT) - * - * Copyright (c) 2014 Jonathan Ong - * Copyright (c) 2015-2018 Douglas Christopher Wilson - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * 'Software'), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; - -export function resolvePath(relativePath: string): string; -export function resolvePath(rootPath: string, relativePath: string): string; -export function resolvePath(rootPath: string, relativePath?: string): string { - let path = relativePath; - let root = rootPath; - - // root is optional, similar to root.resolve - if (relativePath === undefined) { - path = rootPath; - root = "."; - } - - if (path == null) { - throw new TypeError("Argument relativePath is required."); - } - - // containing NULL bytes is malicious - if (path.includes("\0")) { - throw createHttpError(400, "Malicious Path"); - } - - // path should never be absolute - if (isAbsolute(path)) { - throw createHttpError(400, "Malicious Path"); - } - - // path outside root - if (UP_PATH_REGEXP.test(normalize(`.${SEPARATOR}${path}`))) { - throw createHttpError(403); - } - - // join the relative path - return normalize(join(root, path)); -} - -/** A utility class that transforms "any" chunk into an `Uint8Array`. */ -export class Uint8ArrayTransformStream - extends TransformStream { - constructor() { - const init = { - async transform( - chunk: unknown, - controller: TransformStreamDefaultController, - ) { - chunk = await chunk; - switch (typeof chunk) { - case "object": - if (chunk === null) { - controller.terminate(); - } else if (ArrayBuffer.isView(chunk)) { - controller.enqueue( - new Uint8Array( - chunk.buffer, - chunk.byteOffset, - chunk.byteLength, - ), - ); - } else if ( - Array.isArray(chunk) && - chunk.every((value) => typeof value === "number") - ) { - controller.enqueue(new Uint8Array(chunk)); - } else if ( - typeof chunk.valueOf === "function" && chunk.valueOf() !== chunk - ) { - this.transform(chunk.valueOf(), controller); - } else if ("toJSON" in chunk) { - this.transform(JSON.stringify(chunk), controller); - } - break; - case "symbol": - controller.error( - new TypeError("Cannot transform a symbol to a Uint8Array"), - ); - break; - case "undefined": - controller.error( - new TypeError("Cannot transform undefined to a Uint8Array"), - ); - break; - default: - controller.enqueue(this.encoder.encode(String(chunk))); - } - }, - encoder: new TextEncoder(), - }; - super(init); - } -} - -const replacements: Record = { - "/": "_", - "+": "-", - "=": "", -}; - -const encoder = new TextEncoder(); - -export function encodeBase64Safe(data: string | ArrayBuffer): string { - return encodeBase64(data).replace(/\/|\+|=/g, (c) => replacements[c]); -} - -export function isBun(): boolean { - return "Bun" in globalThis; -} - -export function isNode(): boolean { - return "process" in globalThis && "global" in globalThis && - !("Bun" in globalThis) && !("WebSocketPair" in globalThis); -} - -export function importKey(key: Key): Promise { - if (typeof key === "string") { - key = encoder.encode(key); - } else if (Array.isArray(key)) { - key = new Uint8Array(key); - } - return crypto.subtle.importKey( - "raw", - key, - { - name: "HMAC", - hash: { name: "SHA-256" }, - }, - true, - ["sign", "verify"], - ); -} - -export function sign(data: Data, key: CryptoKey): Promise { - if (typeof data === "string") { - data = encoder.encode(data); - } else if (Array.isArray(data)) { - data = Uint8Array.from(data); - } - return crypto.subtle.sign("HMAC", key, data); -} diff --git a/utils/consts.ts b/utils/consts.ts new file mode 100644 index 00000000..d3bb6da0 --- /dev/null +++ b/utils/consts.ts @@ -0,0 +1,4 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +/** Body types which will be coerced into strings before being sent. */ +export const BODY_TYPES = ["string", "number", "bigint", "boolean", "symbol"]; diff --git a/utils/create_promise_with_resolvers.ts b/utils/create_promise_with_resolvers.ts new file mode 100644 index 00000000..c0b2ba32 --- /dev/null +++ b/utils/create_promise_with_resolvers.ts @@ -0,0 +1,20 @@ +/** Memoisation of the feature detection of `Promise.withResolvers` */ +const hasPromiseWithResolvers = "withResolvers" in Promise; + +/** + * Offloads to the native `Promise.withResolvers` when available. + * + * Currently Node.js does not support it, while Deno does. + */ +export function createPromiseWithResolvers(): PromiseWithResolvers { + if (hasPromiseWithResolvers) { + return Promise.withResolvers(); + } + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: resolve!, reject: reject! }; +} diff --git a/utils/decode_component.test.ts b/utils/decode_component.test.ts new file mode 100644 index 00000000..385527da --- /dev/null +++ b/utils/decode_component.test.ts @@ -0,0 +1,12 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +import { assertEquals } from "../test_deps.ts"; +import { decodeComponent } from "./decode_component.ts"; + +Deno.test({ + name: "decodeComponent", + fn() { + // with decodeURIComponent, this would throw: + assertEquals(decodeComponent("%"), "%"); + }, +}); diff --git a/utils/decode_component.ts b/utils/decode_component.ts new file mode 100644 index 00000000..8d85fbb0 --- /dev/null +++ b/utils/decode_component.ts @@ -0,0 +1,13 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +/** + * Safely decode a URI component, where if it fails, instead of throwing, + * just returns the original string + */ +export function decodeComponent(text: string) { + try { + return decodeURIComponent(text); + } catch { + return text; + } +} diff --git a/utils/encode_url.ts b/utils/encode_url.ts new file mode 100644 index 00000000..3135a6a2 --- /dev/null +++ b/utils/encode_url.ts @@ -0,0 +1,14 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +const UNMATCHED_SURROGATE_PAIR_REGEXP = + /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g; +const UNMATCHED_SURROGATE_PAIR_REPLACE = "$1\uFFFD$2"; +const ENCODE_CHARS_REGEXP = + /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g; + +/** Encodes the url preventing double encoding */ +export function encodeUrl(url: string) { + return String(url) + .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) + .replace(ENCODE_CHARS_REGEXP, encodeURI); +} diff --git a/util.test.ts b/utils/resolve_path.test.ts similarity index 66% rename from util.test.ts rename to utils/resolve_path.test.ts index 1352d8d0..f2819130 100644 --- a/util.test.ts +++ b/utils/resolve_path.test.ts @@ -1,16 +1,9 @@ // Copyright 2018-2024 the oak authors. All rights reserved. MIT license. -import { assert, errors } from "./deps.ts"; -import { assertEquals, assertThrows } from "./test_deps.ts"; -import { decodeComponent, getRandomFilename, resolvePath } from "./util.ts"; +import { resolvePath } from "./resolve_path.ts"; -Deno.test({ - name: "decodeComponent", - fn() { - // with decodeURIComponent, this would throw: - assertEquals(decodeComponent("%"), "%"); - }, -}); +import { assert, errors } from "../deps.ts"; +import { assertEquals, assertThrows } from "../test_deps.ts"; Deno.test({ name: "resolvePath", @@ -76,13 +69,3 @@ Deno.test({ ); }, }); - -Deno.test({ - name: "getRandomFilename()", - async fn() { - const actual = await getRandomFilename("foo", "bar"); - assert(actual.startsWith("foo")); - assert(actual.endsWith(".bar")); - assert(actual.length > 7); - }, -}); diff --git a/utils/resolve_path.ts b/utils/resolve_path.ts new file mode 100644 index 00000000..dc1bacbe --- /dev/null +++ b/utils/resolve_path.ts @@ -0,0 +1,73 @@ +/*! + * Adapted directly from https://github.com/pillarjs/resolve-path + * which is licensed as follows: + * + * The MIT License (MIT) + * + * Copyright (c) 2014 Jonathan Ong + * Copyright (c) 2015-2018 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + createHttpError, + isAbsolute, + join, + normalize, + SEPARATOR, +} from "../deps.ts"; + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; + +export function resolvePath(relativePath: string): string; +export function resolvePath(rootPath: string, relativePath: string): string; +export function resolvePath(rootPath: string, relativePath?: string): string { + let path = relativePath; + let root = rootPath; + + // root is optional, similar to root.resolve + if (relativePath === undefined) { + path = rootPath; + root = "."; + } + + if (path == null) { + throw new TypeError("Argument relativePath is required."); + } + + // containing NULL bytes is malicious + if (path.includes("\0")) { + throw createHttpError(400, "Malicious Path"); + } + + // path should never be absolute + if (isAbsolute(path)) { + throw createHttpError(400, "Malicious Path"); + } + + // path outside root + if (UP_PATH_REGEXP.test(normalize(`.${SEPARATOR}${path}`))) { + throw createHttpError(403); + } + + // join the relative path + return normalize(join(root, path)); +} diff --git a/utils/streams.ts b/utils/streams.ts new file mode 100644 index 00000000..6e693d1b --- /dev/null +++ b/utils/streams.ts @@ -0,0 +1,166 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +import { BODY_TYPES } from "./consts.ts"; + +interface Reader { + read(p: Uint8Array): Promise; +} + +interface Closer { + close(): void; +} + +interface ReadableStreamFromReaderOptions { + /** If the `reader` is also a `Closer`, automatically close the `reader` + * when `EOF` is encountered, or a read error occurs. + * + * Defaults to `true`. */ + autoClose?: boolean; + + /** The size of chunks to allocate to read, the default is ~16KiB, which is + * the maximum size that Deno operations can currently support. */ + chunkSize?: number; + + /** The queuing strategy to create the `ReadableStream` with. */ + strategy?: { highWaterMark?: number | undefined; size?: undefined }; +} + +function isCloser(value: unknown): value is Deno.Closer { + return typeof value === "object" && value != null && "close" in value && + // deno-lint-ignore no-explicit-any + typeof (value as Record)["close"] === "function"; +} + +const DEFAULT_CHUNK_SIZE = 16_640; // 17 Kib + +const encoder = new TextEncoder(); + +/** + * Create a `ReadableStream` from a `Reader`. + * + * When the pull algorithm is called on the stream, a chunk from the reader + * will be read. When `null` is returned from the reader, the stream will be + * closed along with the reader (if it is also a `Closer`). + */ +export function readableStreamFromReader( + reader: Reader | (Reader & Closer), + options: ReadableStreamFromReaderOptions = {}, +): ReadableStream { + const { + autoClose = true, + chunkSize = DEFAULT_CHUNK_SIZE, + strategy, + } = options; + + return new ReadableStream({ + async pull(controller) { + const chunk = new Uint8Array(chunkSize); + try { + const read = await reader.read(chunk); + if (read === null) { + if (isCloser(reader) && autoClose) { + reader.close(); + } + controller.close(); + return; + } + controller.enqueue(chunk.subarray(0, read)); + } catch (e) { + controller.error(e); + if (isCloser(reader)) { + reader.close(); + } + } + }, + cancel() { + if (isCloser(reader) && autoClose) { + reader.close(); + } + }, + }, strategy); +} + +/** + * Create a `ReadableStream` from an `AsyncIterable`. + */ +export function readableStreamFromAsyncIterable( + source: AsyncIterable, +): ReadableStream { + return new ReadableStream({ + async start(controller) { + for await (const chunk of source) { + if (BODY_TYPES.includes(typeof chunk)) { + controller.enqueue(encoder.encode(String(chunk))); + } else if (chunk instanceof Uint8Array) { + controller.enqueue(chunk); + } else if (ArrayBuffer.isView(chunk)) { + controller.enqueue(new Uint8Array(chunk.buffer)); + } else if (chunk instanceof ArrayBuffer) { + controller.enqueue(new Uint8Array(chunk)); + } else { + try { + controller.enqueue(encoder.encode(JSON.stringify(chunk))); + } catch { + // we just swallow errors here + } + } + } + controller.close(); + }, + }); +} + +/** A utility class that transforms "any" chunk into an `Uint8Array`. */ +export class Uint8ArrayTransformStream + extends TransformStream { + constructor() { + const init = { + async transform( + chunk: unknown, + controller: TransformStreamDefaultController, + ) { + chunk = await chunk; + switch (typeof chunk) { + case "object": + if (chunk === null) { + controller.terminate(); + } else if (ArrayBuffer.isView(chunk)) { + controller.enqueue( + new Uint8Array( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength, + ), + ); + } else if ( + Array.isArray(chunk) && + chunk.every((value) => typeof value === "number") + ) { + controller.enqueue(new Uint8Array(chunk)); + } else if ( + typeof chunk.valueOf === "function" && chunk.valueOf() !== chunk + ) { + this.transform(chunk.valueOf(), controller); + } else if ("toJSON" in chunk) { + this.transform(JSON.stringify(chunk), controller); + } + break; + case "symbol": + controller.error( + new TypeError("Cannot transform a symbol to a Uint8Array"), + ); + break; + case "undefined": + controller.error( + new TypeError("Cannot transform undefined to a Uint8Array"), + ); + break; + default: + controller.enqueue(this.encoder.encode(String(chunk))); + } + }, + encoder, + }; + super(init); + } +} diff --git a/utils/type_guards.ts b/utils/type_guards.ts new file mode 100644 index 00000000..35e7d7a6 --- /dev/null +++ b/utils/type_guards.ts @@ -0,0 +1,61 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +import type { State } from "../application.ts"; +import type { Context } from "../context.ts"; +import type { RouteParams, RouterContext } from "../router.ts"; +import type { NetAddr } from "../types.ts"; + +import "../node_shims.ts"; + +/** Guard for Async Iterables */ +export function isAsyncIterable( + value: unknown, +): value is AsyncIterable { + return typeof value === "object" && value !== null && + Symbol.asyncIterator in value && + // deno-lint-ignore no-explicit-any + typeof (value as any)[Symbol.asyncIterator] === "function"; +} + +export function isBun(): boolean { + return "Bun" in globalThis; +} + +/** Determines if a string "looks" like HTML */ +export function isHtml(value: string): boolean { + return /^\s*<(?:!DOCTYPE|html|body)/i.test(value); +} + +export function isListenTlsOptions( + value: unknown, +): value is Deno.ListenTlsOptions { + return typeof value === "object" && value !== null && + ("cert" in value || "certFile" in value) && + ("key" in value || "keyFile" in value) && "port" in value; +} + +export function isNetAddr(value: unknown): value is NetAddr { + return typeof value === "object" && value != null && "transport" in value && + "hostname" in value && "port" in value; +} + +export function isNode(): boolean { + return "process" in globalThis && "global" in globalThis && + !("Bun" in globalThis) && !("WebSocketPair" in globalThis); +} + +/** Guard for `Deno.Reader`. */ +export function isReader(value: unknown): value is Deno.Reader { + return typeof value === "object" && value !== null && "read" in value && + typeof (value as Record).read === "function"; +} + +export function isRouterContext< + R extends string, + P extends RouteParams, + S extends State, +>( + value: Context, +): value is RouterContext { + return "params" in value; +}