From c1ce321d6cac434809215fe05b8b6a667b730406 Mon Sep 17 00:00:00 2001 From: KaKa <23028015+climba03003@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:26:02 +0800 Subject: [PATCH] feat: implement abstract send (#199) * feat: implement abstract send * fixup --- .github/workflows/ci-abstract-send.yml | 100 + packages/abstract-send/LICENSE | 24 + packages/abstract-send/README.md | 159 ++ packages/abstract-send/eslint.config.js | 10 + .../fast-decode-uri-component.d.ts | 3 + packages/abstract-send/lib/header-if.ts | 153 + packages/abstract-send/lib/header-ranges.ts | 138 + packages/abstract-send/lib/html.ts | 14 + packages/abstract-send/lib/index.ts | 49 + packages/abstract-send/lib/mime.ts | 69 + packages/abstract-send/lib/options.ts | 206 ++ packages/abstract-send/lib/path-slash.ts | 18 + packages/abstract-send/lib/send-error.ts | 57 + .../abstract-send/lib/send-file-directly.ts | 138 + packages/abstract-send/lib/send-file.ts | 38 + packages/abstract-send/lib/send-index.ts | 29 + .../abstract-send/lib/send-not-modified.ts | 20 + packages/abstract-send/lib/send-redirect.ts | 33 + packages/abstract-send/lib/send-stat-error.ts | 15 + packages/abstract-send/lib/try-stat.ts | 21 + packages/abstract-send/lib/types.ts | 45 + packages/abstract-send/package.json | 62 + .../abstract-send/test/fixtures/.hidden.txt | 1 + .../abstract-send/test/fixtures/.mine/.hidden | 1 + .../test/fixtures/.mine/name.txt | 1 + .../abstract-send/test/fixtures/do..ts.txt | 1 + .../abstract-send/test/fixtures/empty.txt | 0 .../test/fixtures/images/node-js.png | Bin 0 -> 522 bytes .../test/fixtures/name.d/name.txt | 1 + .../test/fixtures/name.dir/name.txt | 1 + .../abstract-send/test/fixtures/name.html | 1 + packages/abstract-send/test/fixtures/name.txt | 1 + packages/abstract-send/test/fixtures/no_ext | 1 + packages/abstract-send/test/fixtures/nums.txt | 1 + .../abstract-send/test/fixtures/pets/.hidden | 1 + .../test/fixtures/pets/index.html | 3 + .../fixtures/snow \342\230\203/index.html" | 0 .../test/fixtures/some thing.txt | 1 + .../test/fixtures/thing.html.html | 1 + .../abstract-send/test/fixtures/tobi.html | 1 + packages/abstract-send/test/send.test.ts | 2532 +++++++++++++++++ packages/abstract-send/test/utils.ts | 39 + packages/abstract-send/tsconfig.cjs.json | 7 + packages/abstract-send/tsconfig.json | 29 + packages/abstract-send/tsconfig.mjs.json | 11 + pnpm-lock.yaml | 630 +++- 46 files changed, 4665 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-abstract-send.yml create mode 100644 packages/abstract-send/LICENSE create mode 100644 packages/abstract-send/README.md create mode 100644 packages/abstract-send/eslint.config.js create mode 100644 packages/abstract-send/fast-decode-uri-component.d.ts create mode 100644 packages/abstract-send/lib/header-if.ts create mode 100644 packages/abstract-send/lib/header-ranges.ts create mode 100644 packages/abstract-send/lib/html.ts create mode 100644 packages/abstract-send/lib/index.ts create mode 100644 packages/abstract-send/lib/mime.ts create mode 100644 packages/abstract-send/lib/options.ts create mode 100644 packages/abstract-send/lib/path-slash.ts create mode 100644 packages/abstract-send/lib/send-error.ts create mode 100644 packages/abstract-send/lib/send-file-directly.ts create mode 100644 packages/abstract-send/lib/send-file.ts create mode 100644 packages/abstract-send/lib/send-index.ts create mode 100644 packages/abstract-send/lib/send-not-modified.ts create mode 100644 packages/abstract-send/lib/send-redirect.ts create mode 100644 packages/abstract-send/lib/send-stat-error.ts create mode 100644 packages/abstract-send/lib/try-stat.ts create mode 100644 packages/abstract-send/lib/types.ts create mode 100644 packages/abstract-send/package.json create mode 100644 packages/abstract-send/test/fixtures/.hidden.txt create mode 100644 packages/abstract-send/test/fixtures/.mine/.hidden create mode 100644 packages/abstract-send/test/fixtures/.mine/name.txt create mode 100644 packages/abstract-send/test/fixtures/do..ts.txt create mode 100644 packages/abstract-send/test/fixtures/empty.txt create mode 100644 packages/abstract-send/test/fixtures/images/node-js.png create mode 100644 packages/abstract-send/test/fixtures/name.d/name.txt create mode 100644 packages/abstract-send/test/fixtures/name.dir/name.txt create mode 100644 packages/abstract-send/test/fixtures/name.html create mode 100644 packages/abstract-send/test/fixtures/name.txt create mode 100644 packages/abstract-send/test/fixtures/no_ext create mode 100644 packages/abstract-send/test/fixtures/nums.txt create mode 100644 packages/abstract-send/test/fixtures/pets/.hidden create mode 100644 packages/abstract-send/test/fixtures/pets/index.html create mode 100644 "packages/abstract-send/test/fixtures/snow \342\230\203/index.html" create mode 100644 packages/abstract-send/test/fixtures/some thing.txt create mode 100644 packages/abstract-send/test/fixtures/thing.html.html create mode 100644 packages/abstract-send/test/fixtures/tobi.html create mode 100644 packages/abstract-send/test/send.test.ts create mode 100644 packages/abstract-send/test/utils.ts create mode 100644 packages/abstract-send/tsconfig.cjs.json create mode 100644 packages/abstract-send/tsconfig.json create mode 100644 packages/abstract-send/tsconfig.mjs.json diff --git a/.github/workflows/ci-abstract-send.yml b/.github/workflows/ci-abstract-send.yml new file mode 100644 index 00000000..29a48926 --- /dev/null +++ b/.github/workflows/ci-abstract-send.yml @@ -0,0 +1,100 @@ +name: Continuous Integration - Abstract Send + +on: + push: + paths: + - ".github/workflows/ci-abstract-send.yml" + - "packages/abstract-send/**" + pull_request: + paths: + - ".github/workflows/ci-abstract-send.yml" + - "packages/abstract-send/**" + +jobs: + linter: + name: Lint Code + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Lint code + run: pnpm --filter "./packages/abstract-send" run lint + + test: + name: Test + needs: linter + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + matrix: + node-version: [20, 22] + os: [macos-latest, ubuntu-latest, windows-latest] + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-${{ matrix.node-version }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.node-version }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build @kakang/unit + run: pnpm --filter "./packages/unit" run build + + - name: Run tests + run: pnpm --filter "./packages/abstract-send" run test diff --git a/packages/abstract-send/LICENSE b/packages/abstract-send/LICENSE new file mode 100644 index 00000000..564eed14 --- /dev/null +++ b/packages/abstract-send/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2012 TJ Holowaychuk +Copyright (c) 2014-2022 Douglas Christopher Wilson +Copyright (c) 2023-2024 The Fastify Team +Copyright (c) 2024 KaKa Ng + +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. diff --git a/packages/abstract-send/README.md b/packages/abstract-send/README.md new file mode 100644 index 00000000..d79f479d --- /dev/null +++ b/packages/abstract-send/README.md @@ -0,0 +1,159 @@ +# @kakang/abstract-send + +The code on this package is based on [`@fastify/send`](https://github.com/fastify/send) +and ported to TypeScript with some additional features. + +Send is a library for streaming files from the file system as an HTTP response +supporting partial responses (Ranges), conditional-GET negotiation (If-Match, +If-Unmodified-Since, If-None-Match, If-Modified-Since), high test coverage, +and granular events which may be leveraged to take appropriate actions in your +application or framework. + +## Installation + +```bash +$ npm install @kakang/abstract-send +``` + +## API + +```js +var send = require('@fastify/send') +``` + +### send(req, path, [options]) + +Provide `statusCode`, `headers` and `stream` for the given path to send to a +`res`. The `req` is the Node.js HTTP request and the `path `is a urlencoded path +to send (urlencoded, not the actual file-system path). + +#### Options + +##### acceptRanges + +Enable or disable accepting ranged requests, defaults to true. +Disabling this will not send `Accept-Ranges` and ignore the contents +of the `Range` request header. + +##### cacheControl + +Enable or disable setting `Cache-Control` response header, defaults to +true. Disabling this will ignore the `immutable` and `maxAge` options. + +##### dotfiles + +Set how "dotfiles" are treated when encountered. A dotfile is a file +or directory that begins with a dot ("."). Note this check is done on +the path itself without checking if the path exists on the +disk. If `root` is specified, only the dotfiles above the root are +checked (i.e. the root itself can be within a dotfile when set +to "deny"). + + - `'allow'` No special treatment for dotfiles. + - `'deny'` Send a 403 for any request for a dotfile. + - `'ignore'` Pretend like the dotfile does not exist and 404. + +The default value is _similar_ to `'ignore'`, with the exception that +this default will not ignore the files within a directory that begins +with a dot, for backward-compatibility. + +##### end + +Byte offset at which the stream ends, defaults to the length of the file +minus 1. The end is inclusive in the stream, meaning `end: 3` will include +the 4th byte in the stream. + +##### etag + +Enable or disable etag generation, defaults to true. + +##### extensions + +If a given file doesn't exist, try appending one of the given extensions, +in the given order. By default, this is disabled (set to `false`). An +example value that will serve extension-less HTML files: `['html', 'htm']`. +This is skipped if the requested file already has an extension. + +##### immutable + +Enable or disable the `immutable` directive in the `Cache-Control` response +header, defaults to `false`. If set to `true`, the `maxAge` option should +also be specified to enable caching. The `immutable` directive will prevent +supported clients from making conditional requests during the life of the +`maxAge` option to check if the file has changed. + +##### index + +By default send supports "index.html" files, to disable this +set `false` or to supply a new index pass a string or an array +in preferred order. + +##### lastModified + +Enable or disable `Last-Modified` header, defaults to true. Uses the file +system's last modified value. + +##### maxAge + +Provide a max-age in milliseconds for HTTP caching, defaults to 0. +This can also be a string accepted by the +[ms](https://www.npmjs.org/package/ms#readme) module. + +##### root + +Serve files relative to `path`. + +##### start + +Byte offset at which the stream starts, defaults to 0. The start is inclusive, +meaning `start: 2` will include the 3rd byte in the stream. + +### .mime + +The `mime` export is a proxy of global instance +of the [`mime` npm module](https://www.npmjs.com/package/mime). +The methods is transformed to async, to resolve the ESM import +on CommonJS environment. + +This is used to configure the MIME types that are associated with file extensions +as well as other options for how to resolve the MIME type of a file (like the +default type to use for an unknown file extension). + +#### .mime.getType(path) + +```js +import { mime } from '@kakang/abstract-send' +await mime.getType('package.json') +``` + +#### .mime.getExtension(type) + +```js +import { mime } from '@kakang/abstract-send' +await mime.getExtension('application/json') +``` + +#### .mime.getAllExtensions(type) + +```js +import { mime } from '@kakang/abstract-send' +await mime.getAllExtensions('application/json') +``` + +#### .mime.define(typeMap, force) + +```js +import { mime } from '@kakang/abstract-send' +await mime.define({ + 'app/new-extension': ['.app'] +}, false) +``` + +#### .mime.default_type + +It is the default type used by this package when +`mime` cannot resolve a proper type. + +## License + +[MIT](LICENSE) diff --git a/packages/abstract-send/eslint.config.js b/packages/abstract-send/eslint.config.js new file mode 100644 index 00000000..ea013b92 --- /dev/null +++ b/packages/abstract-send/eslint.config.js @@ -0,0 +1,10 @@ +'use strict' + +const neostandard = require('neostandard') + +module.exports = [ + { + ignores: ['**/*.d.ts'], + }, + ...neostandard({ ts: true }), +] diff --git a/packages/abstract-send/fast-decode-uri-component.d.ts b/packages/abstract-send/fast-decode-uri-component.d.ts new file mode 100644 index 00000000..ce0fae73 --- /dev/null +++ b/packages/abstract-send/fast-decode-uri-component.d.ts @@ -0,0 +1,3 @@ +declare module 'fast-decode-uri-component' { + export default (path: string): string | undefined +} diff --git a/packages/abstract-send/lib/header-if.ts b/packages/abstract-send/lib/header-if.ts new file mode 100644 index 00000000..4a5fa47c --- /dev/null +++ b/packages/abstract-send/lib/header-if.ts @@ -0,0 +1,153 @@ +import { IncomingMessage } from 'node:http' +import { Headers } from './types' + +const slice = String.prototype.slice + +function parseTokenList (str: string, cb: (match: string) => boolean | undefined): boolean | undefined { + let end = 0 + let start = 0 + let result + + // gather tokens + for (let i = 0, len = str.length; i < len; i++) { + switch (str.charCodeAt(i)) { + case 0x20: /* */ + if (start === end) { + start = end = i + 1 + } + break + case 0x2c: /* , */ + if (start !== end) { + result = cb(slice.call(str, start, end)) + if (result !== undefined) { + return result + } + } + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + // final token + if (start !== end) { + return cb(slice.call(str, start, end)) + } +} + +export function isConditionalGET (request: IncomingMessage) { + return request.headers['if-match'] || + request.headers['if-unmodified-since'] || + request.headers['if-none-match'] || + request.headers['if-modified-since'] +} + +export function isPreconditionFailure (request: IncomingMessage, headers: Headers) { + // if-match + const ifMatch = request.headers['if-match'] + if (ifMatch) { + const etag = headers.ETag + + if (ifMatch !== '*') { + const isMatching = parseTokenList(ifMatch, function (match) { + if ( + match === etag || + 'W/' + match === etag + ) { + return true + } + }) || false + + if (isMatching !== true) { + return true + } + } + } + + // if-unmodified-since + if ('if-unmodified-since' in request.headers) { + const ifUnmodifiedSince = request.headers['if-unmodified-since'] + const unmodifiedSince = Date.parse(ifUnmodifiedSince as string) + // eslint-disable-next-line no-self-compare + if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) + const lastModified = Date.parse(headers['Last-Modified'] as string) + if ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified ||// fast path of isNaN(number) + lastModified > unmodifiedSince + ) { + return true + } + } + } + + return false +} + +export function isNotModifiedFailure (request: IncomingMessage, headers: Headers) { + // Always return stale when Cache-Control: no-cache + // to support end-to-end reload requests + // https://tools.ietf.org/html/rfc2616#section-14.9.4 + if ( + 'cache-control' in request.headers && + (request.headers as any)['cache-control'].indexOf('no-cache') !== -1 + ) { + return false + } + + // if-none-match + if ('if-none-match' in request.headers) { + const ifNoneMatch = request.headers['if-none-match'] as string + + if (ifNoneMatch === '*') { + return true + } + + const etag = headers.ETag + + if (typeof etag !== 'string') { + return false + } + + const etagL = etag.length + const isMatching = parseTokenList(ifNoneMatch, function (match) { + const mL = match.length + + if ( + (etagL === mL && match === etag) || + (etagL > mL && 'W/' + match === etag) + ) { + return true + } + }) + + if (isMatching) { + return true + } + + /** + * A recipient MUST ignore If-Modified-Since if the request contains an + * If-None-Match header field; the condition in If-None-Match is considered + * to be a more accurate replacement for the condition in If-Modified-Since, + * and the two are only combined for the sake of interoperating with older + * intermediaries that might not implement If-None-Match. + * + * @see RFC 9110 section 13.1.3 + */ + return false + } + + // if-modified-since + if ('if-modified-since' in request.headers) { + const ifModifiedSince = request.headers['if-modified-since'] as string + const lastModified = headers['Last-Modified'] as string + + if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { + return true + } + } + + return false +} diff --git a/packages/abstract-send/lib/header-ranges.ts b/packages/abstract-send/lib/header-ranges.ts new file mode 100644 index 00000000..fa063096 --- /dev/null +++ b/packages/abstract-send/lib/header-ranges.ts @@ -0,0 +1,138 @@ +import { IncomingMessage } from 'node:http' +import { Headers } from './types' + +export function isRangeFresh (request: IncomingMessage, headers: Headers): boolean { + if (!('if-range' in request.headers)) { + return true + } + + const ifRange = request.headers['if-range'] as string + + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + const etag = headers.ETag as string + return (etag && ifRange.indexOf(etag) !== -1) || false + } + + const ifRangeTimestamp = Date.parse(ifRange) + // eslint-disable-next-line no-self-compare + if (ifRangeTimestamp !== ifRangeTimestamp) { // fast path of isNaN(number) + return false + } + + // if-range as modified date + const lastModified = Date.parse(headers['Last-Modified'] as string) + + return ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified || // fast path of isNaN(number) + lastModified <= ifRangeTimestamp + ) +} + +export function parseBytesRange (size: number, str: string) { + // split the range string + const values = str.slice(str.indexOf('=') + 1) + const ranges: Array<{ start: number, end: number, index: number }> = [] + + const len = values.length + let i = 0 + let il = 0 + let j = 0 + let start + let end + let commaIdx = values.indexOf(',') + let dashIdx = values.indexOf('-') + let prevIdx = -1 + + // parse all ranges + while (true) { + commaIdx === -1 && (commaIdx = len) + start = parseInt(values.slice(prevIdx + 1, dashIdx), 10) + end = parseInt(values.slice(dashIdx + 1, commaIdx), 10) + + // -nnn + // eslint-disable-next-line no-self-compare + if (start !== start) { // fast path of isNaN(number) + start = size - end + end = size - 1 + // nnn- + // eslint-disable-next-line no-self-compare + } else if (end !== end) { // fast path of isNaN(number) + end = size - 1 + // limit last-byte-pos to current length + } else if (end > size - 1) { + end = size - 1 + } + + // add range only on valid ranges + if ( + // eslint-disable-next-line no-self-compare + start === start && // fast path of isNaN(number) + // eslint-disable-next-line no-self-compare + end === end && // fast path of isNaN(number) + start > -1 && + start <= end + ) { + // add range + ranges.push({ + start, + end, + index: j++ + }) + } + + if (commaIdx === len) { + break + } + prevIdx = commaIdx++ + dashIdx = values.indexOf('-', commaIdx) + commaIdx = values.indexOf(',', commaIdx) + } + + // unsatisfiable + if ( + j < 2 + ) { + return ranges + } + + ranges.sort(sortByRangeStart) + + il = j + j = 0 + i = 1 + while (i < il) { + const range = ranges[i++] + const current = ranges[j] + + if (range.start > current.end + 1) { + // next range + ranges[++j] = range + } else if (range.end > current.end) { + // extend range + current.end = range.end + current.index > range.index && (current.index = range.index) + } + } + + // trim ordered array + ranges.length = j + 1 + + // generate combined range + ranges.sort(sortByRangeIndex) + + return ranges +} + +function sortByRangeIndex (a: { start: number, end: number, index: number }, b: { start: number, end: number, index: number }) { + return a.index - b.index +} + +function sortByRangeStart (a: { start: number, end: number, index: number }, b: { start: number, end: number, index: number }) { + return a.start - b.start +} + +export function contentRange (type: string, size: number, range?: { start: number, end: number, index: number }): string { + return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size +} diff --git a/packages/abstract-send/lib/html.ts b/packages/abstract-send/lib/html.ts new file mode 100644 index 00000000..bbf507bf --- /dev/null +++ b/packages/abstract-send/lib/html.ts @@ -0,0 +1,14 @@ +export function createHtmlDocument (title: string, body: string): [string, number] { + const html = '\n' + + '\n' + + '\n' + + '\n' + + '' + title + '\n' + + '\n' + + '\n' + + '
' + body + '
\n' + + '\n' + + '\n' + + return [html, Buffer.byteLength(html)] +} diff --git a/packages/abstract-send/lib/index.ts b/packages/abstract-send/lib/index.ts new file mode 100644 index 00000000..1bee5194 --- /dev/null +++ b/packages/abstract-send/lib/index.ts @@ -0,0 +1,49 @@ +import { IncomingMessage } from 'http' +import { containDotFile, normalizeOptions, normalizePath, SendOptions } from './options' +import { hasTrailingSlash } from './path-slash' +import { sendError } from './send-error' +import { sendFile } from './send-file' +import { sendIndex } from './send-index' +import { SendResult } from './types' + +export async function send (request: IncomingMessage, requestPath: string, options?: SendOptions): Promise { + const opts = normalizeOptions(options, requestPath) + + const parsed = normalizePath(opts) + if ('statusCode' in parsed) { + return sendError(parsed.statusCode) + } + const { path, parts } = parsed + + // dotfile handling + if ( + opts.dotfiles !== 0 && + containDotFile(parts) + ) { + switch (opts.dotfiles) { + case 0: { + // allow - do nothing + break + } + case 2: { + // deny + return sendError(403) + } + case 1: + default: { + // ignore + return sendError(404) + } + } + } + + // index file support + if (opts.index.length && hasTrailingSlash(opts.path)) { + return sendIndex(request, path, opts) + } + + return sendFile(request, path, opts) +} + +export { isUtf8MimeType, mime } from './mime' +export type { DirectorySendResult, ErrorSendResult, FileSendResult, SendResult } from './types' diff --git a/packages/abstract-send/lib/mime.ts b/packages/abstract-send/lib/mime.ts new file mode 100644 index 00000000..a48d74a8 --- /dev/null +++ b/packages/abstract-send/lib/mime.ts @@ -0,0 +1,69 @@ +import type MIME from 'mime' + +export function isUtf8MimeType (value: string): boolean { + const len = value.length + return ( + (len > 21 && value.indexOf('application/javascript') === 0) || + (len > 14 && value.indexOf('application/json') === 0) || + (len > 5 && value.indexOf('text/') === 0) + ) +} + +export const proto = { + mime: null as never as typeof MIME, + defaultType: 'application/octet-stream' +} + +type CustomImport = (name: string) => Promise<{ default: R }> +// eslint-disable-next-line no-new-func +const _import = new Function('modulePath', 'return import(modulePath)') as CustomImport + +export async function loadMIME (): Promise { + proto.mime ??= (await _import('mime')).default + return proto.mime +} + +export async function getType (path: string): Promise { + return loadMIME().then((mime) => mime.getType(path)) +} + +export async function getExtension (type: string): Promise { + return loadMIME().then((mime) => mime.getExtension(type)) +} + +export async function getAllExtensions (type: string): Promise | null> { + return loadMIME().then((mime) => mime.getAllExtensions(type)) +} + +export async function define (typeMap: { [key: string]: string[] }, force: boolean = false): Promise { + return loadMIME().then((mime) => mime.define(typeMap, force)) +} + +export function getDefaultType () { + return proto.defaultType +} + +export function setDefaultType (type: string) { + proto.defaultType = type +} + +// since we use mime@4, we need to map the function to async +const mime = { + define, + getType, + getExtension, + getAllExtensions +} +// compatible to mime@2 +Object.defineProperties(mime, { + default_type: { + get () { + return getDefaultType() + }, + set (type: string) { + setDefaultType(type) + } + } +}) + +export { mime } diff --git a/packages/abstract-send/lib/options.ts b/packages/abstract-send/lib/options.ts new file mode 100644 index 00000000..f5c3c32d --- /dev/null +++ b/packages/abstract-send/lib/options.ts @@ -0,0 +1,206 @@ +import ms from '@lukeed/ms' +import decode from 'fast-decode-uri-component' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { join, normalize, resolve, sep } from 'node:path' +import { Readable } from 'node:stream' +import { StatsLike } from './types' + +export interface EngineOptions { + stat: (path: string) => StatsLike | Promise + createReadStream: (path: string, options: { start: number, end: number }) => Readable +} + +export interface SendOptions { + acceptRanges?: boolean + cacheControl?: boolean + dotfiles?: 'allow' | 'ignore' | 'deny' + end?: number + etag?: boolean + extensions?: string[] | string | boolean + immutable?: boolean + index?: string[] | string | boolean + lastModified?: boolean + maxage?: string | number + maxAge?: string | number + root?: string + start?: number + engine?: EngineOptions +} + +export interface NormalizedSendOptions { + acceptRanges: boolean + cacheControl: boolean + dotfiles: number + end?: number + etag: boolean + extensions: string[] + immutable: boolean + index: string[] + lastModified: boolean + maxage: number + root: string | null + start?: number + path: string + engine: EngineOptions +} + +const VALID_DOT_FILES_ENUM = [ + 'allow', + 'ignore', + 'deny' +] + +const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ + +function _boolean (option?: boolean, _default: boolean = true): boolean { + return option !== undefined ? Boolean(option) : _default +} + +function _enum (option?: string, options: string[] = [], _default: number = 1): number { + return option !== undefined ? options.indexOf(option) : _default +} + +function _array (option?: boolean | string | string[], name: string = 'option', _default: string[] = []): string[] { + if (option === undefined) { + return _default + } else if (typeof option === 'string') { + return [option] + } else if (option === false) { + return [] + } else if (Array.isArray(option)) { + for (let i = 0, il = option.length; i < il; ++i) { + if (typeof option[i] !== 'string') { + throw new TypeError(name + ' must be array of strings or false') + } + } + return option + } else { + throw new TypeError(name + ' must be array of strings or false') + } +} + +function _number (option?: string | number, min: number = 0, max: number = Number.POSITIVE_INFINITY): number { + let num: number + if (typeof option === 'string') { + num = ms.parse(option) as number + } else { + num = Number(option) + } + + // eslint-disable-next-line no-self-compare + if (num !== num) { + // fast path of isNaN(number) + return 0 + } + + return Math.min(Math.max(min, num), max) +} + +export function normalizeOptions (options: SendOptions | undefined, path: string): NormalizedSendOptions { + options ??= {} + + const acceptRanges = _boolean(options.acceptRanges, true) + + const cacheControl = _boolean(options.cacheControl, true) + + const etag = _boolean(options.etag, true) + + const dotfiles = _enum(options.dotfiles, VALID_DOT_FILES_ENUM, 1) // 'ignore' + if (dotfiles === -1) { + throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') + } + + const extensions = _array(options.extensions, 'extensions option', []) + + const immutable = _boolean(options.immutable, false) + + const index = _array(options.index, 'index option', ['index.html']) + + const lastModified = _boolean(options.lastModified, true) + + const maxage = _number(options.maxAge ?? options.maxAge, 0, MAX_MAXAGE) + + const root = options.root ? resolve(options.root) : null + + const engine: EngineOptions = options.engine + ? options.engine + : { + stat, + createReadStream + } + + return { + acceptRanges, + cacheControl, + etag, + dotfiles, + extensions, + immutable, + index, + lastModified, + maxage, + root, + start: options.start, + end: options.end, + path, + engine + } +} + +export function normalizePath (options: NormalizedSendOptions): { statusCode: number } | { path: string, parts: string[] } { + // decode the path + let path = decode(options.path) + if (path == null) { + return { statusCode: 400 } + } + + // null byte(s) + if (~path.indexOf('\0')) { + return { statusCode: 400 } + } + + let parts + if (options.root !== null) { + // normalize + if (path) { + path = normalize('.' + sep + path) + } + + // malicious path + if (UP_PATH_REGEXP.test(path)) { + return { statusCode: 403 } + } + + // explode path parts + parts = path.split(sep) + + // join / normalize from optional root dir + path = normalize(join(options.root, path)) + } else { + // ".." is malicious without "root" + if (UP_PATH_REGEXP.test(path)) { + return { statusCode: 403 } + } + + // explode path parts + parts = normalize(path).split(sep) + + // resolve the path + path = resolve(path) + } + + return { path, parts } +} + +export function containDotFile (parts: string[]): boolean { + for (let i = 0, il = parts.length; i < il; ++i) { + if (parts[i].length !== 1 && parts[i][0] === '.') { + return true + } + } + + return false +} diff --git a/packages/abstract-send/lib/path-slash.ts b/packages/abstract-send/lib/path-slash.ts new file mode 100644 index 00000000..c71e4c78 --- /dev/null +++ b/packages/abstract-send/lib/path-slash.ts @@ -0,0 +1,18 @@ +export function hasTrailingSlash (path: string): boolean { + return path[path.length - 1] === '/' +} + +export function collapseLeadingSlashes (str: string) { + if ( + str[0] !== '/' || + str[1] !== '/' + ) { + return str + } + for (let i = 2, il = str.length; i < il; ++i) { + if (str[i] !== '/') { + return str.slice(i - 1) + } + } + /* c8 ignore next */ +} diff --git a/packages/abstract-send/lib/send-error.ts b/packages/abstract-send/lib/send-error.ts new file mode 100644 index 00000000..706be9fa --- /dev/null +++ b/packages/abstract-send/lib/send-error.ts @@ -0,0 +1,57 @@ +import createError from 'http-errors' +import { Readable } from 'stream' +import { createHtmlDocument } from './html' +import { ErrorSendResult } from './types' + +export type ErrorOrHeaders = Error | { + headers: { + [key: string]: string | number + } +} + +const ERROR_RESPONSES = { + 400: createHtmlDocument('Error', 'Bad Request'), + 403: createHtmlDocument('Error', 'Forbidden'), + 404: createHtmlDocument('Error', 'Not Found'), + 412: createHtmlDocument('Error', 'Precondition Failed'), + 416: createHtmlDocument('Error', 'Range Not Satisfiable'), + 500: createHtmlDocument('Error', 'Internal Server Error') +} + +function createHttpError (statusCode: number, err?: ErrorOrHeaders) { + if (!err) { + return createError(statusCode) + } + + return err instanceof Error + ? createError(statusCode, err, { expose: false }) + : createError(statusCode, err) +} + +export function sendError (statusCode: number, err?: ErrorOrHeaders): ErrorSendResult { + const headers: { [key: string]: string | number } = {} + + // add error headers + if (err && 'headers' in err) { + for (const headerName in err.headers) { + headers[headerName] = err.headers[headerName] + } + } + + const doc = ERROR_RESPONSES[statusCode as keyof typeof ERROR_RESPONSES] + + // basic response + headers['Content-Type'] = 'text/html; charset=UTF-8' + headers['Content-Length'] = doc[1] + headers['Content-Security-Policy'] = "default-src 'none'" + headers['X-Content-Type-Options'] = 'nosniff' + + return { + statusCode, + headers, + stream: Readable.from(doc[0]), + // metadata + type: 'error', + metadata: { error: createHttpError(statusCode, err) } + } +} diff --git a/packages/abstract-send/lib/send-file-directly.ts b/packages/abstract-send/lib/send-file-directly.ts new file mode 100644 index 00000000..8cc2e23c --- /dev/null +++ b/packages/abstract-send/lib/send-file-directly.ts @@ -0,0 +1,138 @@ +import { IncomingMessage } from 'http' +import { Readable } from 'node:stream' +import { isConditionalGET, isNotModifiedFailure, isPreconditionFailure } from './header-if' +import { contentRange, isRangeFresh, parseBytesRange } from './header-ranges' +import { getType, isUtf8MimeType, proto } from './mime' +import { NormalizedSendOptions } from './options' +import { sendError } from './send-error' +import { sendNotModified } from './send-not-modified' +import { Headers, SendResult, StatsLike } from './types' + +const BYTES_RANGE_REGEXP = /^ *bytes=/ + +export async function sendFileDirectly (request: IncomingMessage, path: string, stat: StatsLike, options: NormalizedSendOptions): Promise { + let len = stat.size + let offset = options.start ?? 0 + + let statusCode = 200 + const headers: Headers = {} + + // set header fields + if (options.acceptRanges) { + headers['Accept-Ranges'] = 'bytes' + } + + if (options.cacheControl) { + let cacheControl = 'public, max-age=' + Math.floor(options.maxage / 1000) + + if (options.immutable) { + cacheControl += ', immutable' + } + + headers['Cache-Control'] = cacheControl + } + + if (options.lastModified) { + const modified = stat.mtime.toUTCString() + headers['Last-Modified'] = modified + } + + if (options.etag) { + const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' + headers.ETag = etag + } + + // set content-type + let type = await getType(path) || proto.defaultType + if (type && isUtf8MimeType(type)) { + type += '; charset=UTF-8' + } + if (type) { + headers['Content-Type'] = type + } + + // conditional GET support + if (isConditionalGET(request)) { + if (isPreconditionFailure(request, headers)) { + return sendError(412) + } + + if (isNotModifiedFailure(request, headers)) { + return sendNotModified(headers, path, stat) + } + } + + // adjust len to start/end options + len = Math.max(0, len - offset) + if (options.end !== undefined) { + const bytes = options.end - offset + 1 + if (len > bytes) len = bytes + } + + // Range support + if (options.acceptRanges) { + const rangeHeader = request.headers.range + + if ( + rangeHeader !== undefined && + BYTES_RANGE_REGEXP.test(rangeHeader) + ) { + // If-Range support + if (isRangeFresh(request, headers)) { + // parse + const ranges = parseBytesRange(len, rangeHeader) + + // unsatisfiable + if (ranges.length === 0) { + // Content-Range + headers['Content-Range'] = contentRange('bytes', len) + + // 416 Requested Range Not Satisfiable + return sendError(416, { + headers: { 'Content-Range': headers['Content-Range'] } + }) + // valid (syntactically invalid/multiple ranges are treated as a regular response) + } else if (ranges.length === 1) { + // Content-Range + statusCode = 206 + headers['Content-Range'] = contentRange('bytes', len, ranges[0]) + + // adjust for requested range + offset += ranges[0].start + len = ranges[0].end - ranges[0].start + 1 + } + } else { + // unexpected + } + } + } + + // content-length + headers['Content-Length'] = len + + // HEAD support + if (request.method === 'HEAD') { + return { + statusCode, + headers, + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } + } + } + + const stream = options.engine.createReadStream(path, { + start: offset, + end: Math.max(offset, offset + len - 1) + }) + + return { + statusCode, + headers, + stream, + // metadata + type: 'file', + metadata: { path, stat } + } +} diff --git a/packages/abstract-send/lib/send-file.ts b/packages/abstract-send/lib/send-file.ts new file mode 100644 index 00000000..04f2ff4b --- /dev/null +++ b/packages/abstract-send/lib/send-file.ts @@ -0,0 +1,38 @@ +import { IncomingMessage } from 'http' +import { extname, sep } from 'node:path' +import { SendResult } from '.' +import { NormalizedSendOptions } from './options' +import { sendError } from './send-error' +import { sendFileDirectly } from './send-file-directly' +import { sendRedirect } from './send-redirect' +import { sendStatError } from './send-stat-error' +import { tryStat } from './try-stat' + +export async function sendFile (request: IncomingMessage, path: string, options: NormalizedSendOptions): Promise { + const { error, stat } = await tryStat(path, options) + if (error && (error as any).code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { + let err: Error | null = error + // not found, check extensions + for (let i = 0; i < options.extensions.length; i++) { + const extension = options.extensions[i] + const p = path + '.' + extension + const { error, stat } = await tryStat(p, options) + if (error) { + err = error + continue + } + if (stat.isDirectory()) { + err = null + continue + } + return sendFileDirectly(request, p, stat, options) + } + if (err) { + return sendStatError(err) + } + return sendError(404) + } + if (error) return sendStatError(error) + if (stat.isDirectory()) return sendRedirect(path, options) + return sendFileDirectly(request, path, stat, options) +} diff --git a/packages/abstract-send/lib/send-index.ts b/packages/abstract-send/lib/send-index.ts new file mode 100644 index 00000000..e23b902e --- /dev/null +++ b/packages/abstract-send/lib/send-index.ts @@ -0,0 +1,29 @@ +import { IncomingMessage } from 'http' +import { join } from 'node:path' +import { SendResult } from '.' +import { NormalizedSendOptions } from './options' +import { sendError } from './send-error' +import { sendFileDirectly } from './send-file-directly' +import { sendStatError } from './send-stat-error' +import { tryStat } from './try-stat' + +export async function sendIndex (request: IncomingMessage, path: string, options: NormalizedSendOptions): Promise { + let err: Error | null = null + for (let i = 0; i < options.index.length; i++) { + const index = options.index[i] + const p = join(path, index) + const { error, stat } = await tryStat(p, options) + if (error) { + err = error + continue + } + if (stat.isDirectory()) continue + return sendFileDirectly(request, p, stat, options) + } + + if (err) { + return sendStatError(err) + } + + return sendError(404) +} diff --git a/packages/abstract-send/lib/send-not-modified.ts b/packages/abstract-send/lib/send-not-modified.ts new file mode 100644 index 00000000..40d516fc --- /dev/null +++ b/packages/abstract-send/lib/send-not-modified.ts @@ -0,0 +1,20 @@ +import { Readable } from 'node:stream' +import { SendResult } from '.' +import { Headers, StatsLike } from './types' + +export function sendNotModified (headers: Headers, path: string, stat: StatsLike): SendResult { + delete headers['Content-Encoding'] + delete headers['Content-Language'] + delete headers['Content-Length'] + delete headers['Content-Range'] + delete headers['Content-Type'] + + return { + statusCode: 304, + headers, + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } + } +} diff --git a/packages/abstract-send/lib/send-redirect.ts b/packages/abstract-send/lib/send-redirect.ts new file mode 100644 index 00000000..5620ec02 --- /dev/null +++ b/packages/abstract-send/lib/send-redirect.ts @@ -0,0 +1,33 @@ +import escapeHtml from 'escape-html' +import { Readable } from 'node:stream' +import { createHtmlDocument } from './html' +import { NormalizedSendOptions } from './options' +import { collapseLeadingSlashes, hasTrailingSlash } from './path-slash' +import { sendError } from './send-error' +import { Headers, SendResult } from './types' + +export function sendRedirect (path: string, options: NormalizedSendOptions): SendResult { + if (hasTrailingSlash(options.path)) { + return sendError(403) + } + + const loc = encodeURI(collapseLeadingSlashes(options.path + '/') as string) + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + + escapeHtml(loc) + '') + + const headers: Headers = {} + headers['Content-Type'] = 'text/html; charset=UTF-8' + headers['Content-Length'] = doc[1] + headers['Content-Security-Policy'] = "default-src 'none'" + headers['X-Content-Type-Options'] = 'nosniff' + headers.Location = loc + + return { + statusCode: 301, + headers, + stream: Readable.from(doc[0]), + // metadata + type: 'directory', + metadata: { requestPath: options.path, path } + } +} diff --git a/packages/abstract-send/lib/send-stat-error.ts b/packages/abstract-send/lib/send-stat-error.ts new file mode 100644 index 00000000..8af36745 --- /dev/null +++ b/packages/abstract-send/lib/send-stat-error.ts @@ -0,0 +1,15 @@ +import { sendError } from './send-error' + +export function sendStatError (err: Error & { code?: string }) { + // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT + /* c8 ignore start */ + switch (err.code) { + case 'ENAMETOOLONG': + case 'ENOTDIR': + case 'ENOENT': + return sendError(404, err) + default: + return sendError(500, err) + } + /* c8 ignore stop */ +} diff --git a/packages/abstract-send/lib/try-stat.ts b/packages/abstract-send/lib/try-stat.ts new file mode 100644 index 00000000..34188020 --- /dev/null +++ b/packages/abstract-send/lib/try-stat.ts @@ -0,0 +1,21 @@ +import { NormalizedSendOptions } from './options' +import { StatsLike } from './types' + +interface SuccessStatResult { + error: null + stat: StatsLike +} + +interface ErrorStatResult { + error: Error + stat: undefined +} + +export async function tryStat (path: string, options: NormalizedSendOptions): Promise { + try { + const stat = await options.engine.stat(path) + return { error: null, stat } + } catch (error) { + return { error: error as Error, stat: undefined } + } +} diff --git a/packages/abstract-send/lib/types.ts b/packages/abstract-send/lib/types.ts new file mode 100644 index 00000000..3e63c554 --- /dev/null +++ b/packages/abstract-send/lib/types.ts @@ -0,0 +1,45 @@ +import { Stats } from 'node:fs' +import { Readable } from 'node:stream' + +export interface Headers { + [key: string]: string | number +} + +export interface StatsLike extends Partial { + // this is the only two fields we need + // others is optional + size: number + mtime: Date + isDirectory: () => boolean +} + +export interface BaseSendResult { + statusCode: number + headers: Headers + stream: Readable +} + +export interface FileSendResult extends BaseSendResult { + type: 'file' + metadata: { + path: string + stat: StatsLike + } +} + +export interface DirectorySendResult extends BaseSendResult { + type: 'directory' + metadata: { + path: string + requestPath: string + } +} + +export interface ErrorSendResult extends BaseSendResult { + type: 'error' + metadata: { + error: Error + } +} + +export type SendResult = FileSendResult | DirectorySendResult | ErrorSendResult diff --git a/packages/abstract-send/package.json b/packages/abstract-send/package.json new file mode 100644 index 00000000..d8234941 --- /dev/null +++ b/packages/abstract-send/package.json @@ -0,0 +1,62 @@ +{ + "name": "@kakang/abstract-send", + "version": "0.0.1", + "description": "", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "directories": { + "lib": "lib" + }, + "exports": { + ".": { + "import": "./lib/mjs/index.js", + "require": "./lib/index.js" + } + }, + "scripts": { + "clean": "node ../../scripts/build.mjs --clean", + "lint": "eslint", + "lint:fix": "npm run lint -- --fix", + "build": "node ../../scripts/build.mjs --build=\"all\"", + "build:cjs": "node ../../scripts/build.mjs --build='cjs'", + "build:mjs": "node ../../scripts/build.mjs --build='mjs'", + "unit": "cross-env \"NODE_OPTIONS=--require ts-node/register\" unit", + "test": "npm run lint && npm run unit", + "coverage": "cross-env \"NODE_OPTIONS=--require ts-node/register\" c8 unit", + "prepublishOnly": "npm run build", + "postpublish": "npm run clean" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "https://github.com/kaka-ng/nodejs.git" + }, + "author": "KaKa ", + "license": "MIT", + "devDependencies": { + "@kakang/unit": "workspace:^", + "@types/escape-html": "^1.0.4", + "@types/http-errors": "^2.0.4", + "@types/node": "^22.5.4", + "@types/supertest": "^6.0.2", + "c8": "^10.1.2", + "cross-env": "^7.0.3", + "eslint": "^9.9.1", + "neostandard": "^0.11.4", + "rimraf": "^6.0.1", + "supertest": "^6.0.0", + "ts-node": "^10.9.2", + "tsc-alias": "^1.8.10", + "typescript": "~5.5.4" + }, + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "^1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^4.0.4" + } +} diff --git a/packages/abstract-send/test/fixtures/.hidden.txt b/packages/abstract-send/test/fixtures/.hidden.txt new file mode 100644 index 00000000..536aca34 --- /dev/null +++ b/packages/abstract-send/test/fixtures/.hidden.txt @@ -0,0 +1 @@ +secret \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/.mine/.hidden b/packages/abstract-send/test/fixtures/.mine/.hidden new file mode 100644 index 00000000..d97c5ead --- /dev/null +++ b/packages/abstract-send/test/fixtures/.mine/.hidden @@ -0,0 +1 @@ +secret diff --git a/packages/abstract-send/test/fixtures/.mine/name.txt b/packages/abstract-send/test/fixtures/.mine/name.txt new file mode 100644 index 00000000..fa66f37f --- /dev/null +++ b/packages/abstract-send/test/fixtures/.mine/name.txt @@ -0,0 +1 @@ +tobi \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/do..ts.txt b/packages/abstract-send/test/fixtures/do..ts.txt new file mode 100644 index 00000000..90a1d60a --- /dev/null +++ b/packages/abstract-send/test/fixtures/do..ts.txt @@ -0,0 +1 @@ +... \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/empty.txt b/packages/abstract-send/test/fixtures/empty.txt new file mode 100644 index 00000000..e69de29b diff --git a/packages/abstract-send/test/fixtures/images/node-js.png b/packages/abstract-send/test/fixtures/images/node-js.png new file mode 100644 index 0000000000000000000000000000000000000000..49f17ca39b7e0432768aa02d93782808dda894ab GIT binary patch literal 522 zcmV+l0`>igP)7PuNGdqWW(+2f~wqM#5 z(spU1>9$?idB?V)X#A}s<88)!T?=ER+#2;yVmiRP8i*gofhWk& z-$4!dDxV(#Y~zUh3@)6BQ6Qh*3h@bK7m#fcpjpM$A7XVUq)x2+K^Ow3)uDcNy&?a% z5};^c_}najn9;#&;Kgi-UMs{wM4M&bfnKiu64SU^$6%j)k&}pgmha~Tc+AcPaj}=Y z6X>7u(tgM(#9HGuvKyRiuDS@%~azkz^(;S2CgIs5^6jMxlrtHW?5 z0h<-!ELVDoHK2m4lZgLOPBznntRHcMgRSNqcvAh0b1ih5IXHOiU0rr M07*qoM6N<$g8r2C0{{R3 literal 0 HcmV?d00001 diff --git a/packages/abstract-send/test/fixtures/name.d/name.txt b/packages/abstract-send/test/fixtures/name.d/name.txt new file mode 100644 index 00000000..789c47ab --- /dev/null +++ b/packages/abstract-send/test/fixtures/name.d/name.txt @@ -0,0 +1 @@ +loki \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/name.dir/name.txt b/packages/abstract-send/test/fixtures/name.dir/name.txt new file mode 100644 index 00000000..fa66f37f --- /dev/null +++ b/packages/abstract-send/test/fixtures/name.dir/name.txt @@ -0,0 +1 @@ +tobi \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/name.html b/packages/abstract-send/test/fixtures/name.html new file mode 100644 index 00000000..52b19230 --- /dev/null +++ b/packages/abstract-send/test/fixtures/name.html @@ -0,0 +1 @@ +

tobi

\ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/name.txt b/packages/abstract-send/test/fixtures/name.txt new file mode 100644 index 00000000..fa66f37f --- /dev/null +++ b/packages/abstract-send/test/fixtures/name.txt @@ -0,0 +1 @@ +tobi \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/no_ext b/packages/abstract-send/test/fixtures/no_ext new file mode 100644 index 00000000..f6ea0495 --- /dev/null +++ b/packages/abstract-send/test/fixtures/no_ext @@ -0,0 +1 @@ +foobar \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/nums.txt b/packages/abstract-send/test/fixtures/nums.txt new file mode 100644 index 00000000..e2e107ac --- /dev/null +++ b/packages/abstract-send/test/fixtures/nums.txt @@ -0,0 +1 @@ +123456789 \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/pets/.hidden b/packages/abstract-send/test/fixtures/pets/.hidden new file mode 100644 index 00000000..d97c5ead --- /dev/null +++ b/packages/abstract-send/test/fixtures/pets/.hidden @@ -0,0 +1 @@ +secret diff --git a/packages/abstract-send/test/fixtures/pets/index.html b/packages/abstract-send/test/fixtures/pets/index.html new file mode 100644 index 00000000..5106b6f5 --- /dev/null +++ b/packages/abstract-send/test/fixtures/pets/index.html @@ -0,0 +1,3 @@ +tobi +loki +jane \ No newline at end of file diff --git "a/packages/abstract-send/test/fixtures/snow \342\230\203/index.html" "b/packages/abstract-send/test/fixtures/snow \342\230\203/index.html" new file mode 100644 index 00000000..e69de29b diff --git a/packages/abstract-send/test/fixtures/some thing.txt b/packages/abstract-send/test/fixtures/some thing.txt new file mode 100644 index 00000000..2b31011c --- /dev/null +++ b/packages/abstract-send/test/fixtures/some thing.txt @@ -0,0 +1 @@ +hey \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/thing.html.html b/packages/abstract-send/test/fixtures/thing.html.html new file mode 100644 index 00000000..d5644325 --- /dev/null +++ b/packages/abstract-send/test/fixtures/thing.html.html @@ -0,0 +1 @@ +

trap!

\ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/tobi.html b/packages/abstract-send/test/fixtures/tobi.html new file mode 100644 index 00000000..52b19230 --- /dev/null +++ b/packages/abstract-send/test/fixtures/tobi.html @@ -0,0 +1 @@ +

tobi

\ No newline at end of file diff --git a/packages/abstract-send/test/send.test.ts b/packages/abstract-send/test/send.test.ts new file mode 100644 index 00000000..58024f03 --- /dev/null +++ b/packages/abstract-send/test/send.test.ts @@ -0,0 +1,2532 @@ +'use strict' + +import fs from 'node:fs' +import { readdir } from 'node:fs/promises' +import http from 'node:http' +import path from 'node:path' +import { test, TestContext } from 'node:test' +import request from 'supertest' +import { send } from '../lib' +import { createServer, shouldNotHaveBody, shouldNotHaveHeader, withResolvers } from './utils' + +// test server + +const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file, options)', async function (t: TestContext) { + await t.test('acceptRanges', async function (t: TestContext) { + await t.test('should support disabling accept-ranges', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ acceptRanges: false, root: fixtures })) + .get('/nums.txt') + .expect(shouldNotHaveHeader('Accept-Ranges', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await await t.test('should ignore requested range', async function (t: TestContext) { + t.plan(3) + + const { promise, resolve } = withResolvers() + request(createServer({ acceptRanges: false, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=0-2') + .expect(shouldNotHaveHeader('Accept-Ranges', t)) + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('cacheControl', async function (t: TestContext) { + await t.test('should support disabling cache-control', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ cacheControl: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Cache-Control', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should ignore maxAge option', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Cache-Control', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('etag', async function (t: TestContext) { + await t.test('should support disabling etags', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ etag: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('ETag', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('extensions', async function (t: TestContext) { + await t.test('should reject numbers', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + // @ts-expect-error - check invalid value + request(createServer({ extensions: 42, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should reject true', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: true, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be not be enabled by default', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/tobi') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be configurable', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: 'txt', root: fixtures })) + .get('/name') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support disabling extensions', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: false, root: fixtures })) + .get('/name') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support fallbacks', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/name') + .expect(200, '

tobi

', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if nothing found', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/bob') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should skip directories', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: ['file', 'dir'], root: fixtures })) + .get('/name') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not search if file has extension', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: 'html', root: fixtures })) + .get('/thing.html') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('lastModified', async function (t: TestContext) { + await t.test('should support disabling last-modified', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ lastModified: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Last-Modified', t)) + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('dotfiles', async function (t: TestContext) { + const { promise, resolve } = withResolvers() + await t.test('should default to "ignore"', async function (t: TestContext) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/.hidden.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should reject bad value', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + // @ts-expect-error - check invalid value + request(createServer({ dotfiles: 'bogus' })) + .get('/name.txt') + .expect(500, /dotfiles/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('when "allow"', async function (t: TestContext) { + await t.test('should send dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.hidden.txt') + .expect(200, 'secret', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should send within dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.mine/name.txt') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for non-existent dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.nothere') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when "deny"', async function (t: TestContext) { + await t.test('should 403 for dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.hidden.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile directory with trailing slash', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for file within dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for non-existent dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.nothere') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for non-existent dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.what/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile in directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/pets/.hidden') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile in dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/.hidden') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should send files in root dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile without root', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url as string, { dotfiles: 'deny' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when "ignore"', async function (t: TestContext) { + await t.test('should 404 for dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.hidden.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for dotfile directory with trailing slash', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for file within dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/name.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for non-existent dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.nothere') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for non-existent dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.what/name.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should send files in root dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for dotfile without root', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url as string, { dotfiles: 'ignore' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('immutable', async function (t: TestContext) { + await t.test('should default to false', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set immutable directive in Cache-Control', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=3600, immutable', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('maxAge', async function (t: TestContext) { + await t.test('should default to 0', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should floor to integer', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ maxAge: 123956, root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=123', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should accept string', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ maxAge: '30d', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=2592000', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should max at 1 year', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ maxAge: '2y', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=31536000', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('index', async function (t: TestContext) { + await t.test('should reject numbers', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + // @ts-expect-error - check invalid value + request(createServer({ root: fixtures, index: 42 })) + .get('/pets/') + .expect(500, /TypeError: index option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should reject true', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: true })) + .get('/pets/') + .expect(500, /TypeError: index option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should default to index.html', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/') + .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be configurable', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: 'tobi.html' })) + .get('/') + .expect(200, '

tobi

', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support disabling', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: false })) + .get('/pets/') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support fallbacks', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) + .get('/pets/') + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if no index file found (file)', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: 'default.htm' })) + .get('/pets/') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if no index file found (dir)', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: 'pets' })) + .get('/') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not follow directories', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) + .get('/') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should work without root', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function (req, res) { + const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' + const { statusCode, headers, stream } = await send(req, p, { index: ['index.html'] }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('root', async function (t: TestContext) { + await t.test('when given', async function (t: TestContext) { + await t.test('should join root', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/../name.txt') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should work with trailing slash', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures + '/' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should work with empty path', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(301, /Redirecting to/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + // + // NOTE: This is not a real part of the API, but + // over time this has become something users + // are doing, so this will prevent unseen + // regressions around this use-case. + // + await t.test('should try as file with empty path', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: path.join(fixtures, 'name.txt') }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should restrict paths to within root', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/../../send.js') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should allow .. in root', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures + '/../fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/pets/../../send.js') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not allow root transversal', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: path.join(fixtures, 'name.d') })) + .get('/../name.dir/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not allow root path disclosure', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/../../fixtures/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when missing', async function (t: TestContext) { + await t.test('should consider .. malicious', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/../send.js') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should still serve files with dots in name', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/do..ts.txt') + .expect(200, '...', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('should stream the file contents', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should stream a zero-length file', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should decode the given path as a URI', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/some%20thing.txt') + .expect(200, 'hey', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should serve files with dots in name', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/do..ts.txt') + .expect(200, '...', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should treat a malformed URI as a bad request', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/some%99thing.txt') + .expect(400, /Bad Request/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 400 on NULL bytes', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/some%00thing.txt') + .expect(400, /Bad Request/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should treat an ENAMETOOLONG as a 404', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support HEAD', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody(t)) + .end(err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add an ETag header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + .end(err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add a Date header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('date', dateRegExp, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add a Last-Modified header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('last-modified', dateRegExp, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add a Accept-Ranges header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if the file does not exist', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/meow') + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if the filename is too long', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const longFilename = new Array(512).fill('a').join('') + + const { promise, resolve } = withResolvers() + request(app) + .get('/' + longFilename) + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if the requested resource is not a directory', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt/invalid') + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not override content-type', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'Content-Type': 'application/x-custom' + }) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Content-Type', 'application/x-custom', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set Content-Type via mime map', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, function (err) { + request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=UTF-8') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('send directory', async function (t: TestContext) { + await t.test('should redirect directories to trailing slash', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with an HTML redirect', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/pets\/<\/a> { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with default Content-Security-Policy', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not redirect to protocol-relative locations', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('//pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with an HTML redirect', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, (req.url as string).replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/snow') + .expect('Location', '/snow%20%E2%98%83/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('send error', async function (t: TestContext) { + await t.test('should respond to errors directly', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/foobar') + .expect(404, />Not Found { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with default Content-Security-Policy', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/foobar') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('with conditional-GET', async function (t: TestContext) { + await t.test('should remove Content headers with 304', async function (t: TestContext) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('Content-Language', 'en-US') + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Contents', 'foo') + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not remove all Content-* headers', async function (t: TestContext) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Content-Security-Policy', 'default-src \'self\'') + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Content-Security-Policy', 'default-src \'self\'') + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('where "If-Match" is set', async function (t: TestContext) { + await t.test('should respond with 200 when "*"', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .set('If-Match', '*') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 412 when ETag unmatched', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .set('If-Match', ' "foo",, "bar" ,') + .expect(412, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag matched /1', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Match', '"foo", "bar", ' + res.headers.etag) + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag matched /2', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('where "If-Modified-Since" is set', async function (t: TestContext) { + await t.test('should respond with 304 when unmodified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when modified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + const lmod = new Date(res.headers['last-modified']) as never as number + const date = new Date(lmod - 60000) + request(app) + .get('/name.txt') + .set('If-Modified-Since', date.toUTCString()) + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when modified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('where "If-None-Match" is set', async function (t: TestContext) { + await t.test('should respond with 304 when ETag matched', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag unmatched', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag is not generated', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 306 Not Modified when using wildcard * on existing file', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', '*') + .expect(304, '', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 404 Not Found when using wildcard * on non-existing file', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/asdf.txt') + .set('If-None-Match', '*') + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 cache-control is set to no-cache', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('where "If-Unmodified-Since" is set', async function (t: TestContext) { + await t.test('should respond with 200 when unmodified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', res.headers['last-modified']) + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 412 when modified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + const lmod = new Date(res.headers['last-modified']) as never as number + const date = new Date(lmod - 60000).toUTCString() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', date) + .expect(412, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when invalid date', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', 'foo') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('with Range request', async function (t: TestContext) { + await t.test('should support byte ranges', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, '12345', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should ignore non-byte ranges', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'items=0-4') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be inclusive', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-0') + .expect(206, '1', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set Content-Range', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-5') + .expect('Content-Range', 'bytes 2-5/9') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support -n', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=-3') + .expect(206, '789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support n-', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=3-') + .expect(206, '456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 206 "Partial Content"', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set Content-Length to the # of octets transferred', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-3') + .expect('Content-Length', '2') + .expect(206, '34', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('when last-byte-pos of the range is greater the length', async function (t: TestContext) { + await t.test('is taken to be equal to one less than the length', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Range', 'bytes 2-8/9') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should adapt the Content-Length accordingly', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Length', '7') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when the first- byte-pos of the range is greater length', async function (t: TestContext) { + await t.test('should respond with 416', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('Content-Range', 'bytes */9') + .expect(416, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should emit error 416 with content-range header', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'X-Content-Range': headers['Content-Range'] + }) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('X-Content-Range', 'bytes */9') + .expect(416, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when syntactically invalid', async function (t: TestContext) { + await t.test('should respond with 200 and the entire contents', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'asdf') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when multiple ranges', async function (t: TestContext) { + await t.test('should respond with 200 and the entire contents', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-1,3-') + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 206 is all ranges can be combined', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-5') + .expect('Content-Range', 'bytes 1-5/9') + .expect(206, '23456', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when if-range present', async function (t: TestContext) { + await t.test('should respond with parts when etag unchanged', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const etag = res.headers.etag + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when etag changed', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const etag = res.headers.etag.replace(/"(.)/, '"0$1') + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with parts when modified unchanged', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const modified = res.headers['last-modified'] + + request(app) + .get('/nums.txt') + .set('If-Range', modified) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when modified changed', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const modified = Date.parse(res.headers['last-modified']) - 20000 + + request(app) + .get('/nums.txt') + .set('If-Range', new Date(modified).toUTCString()) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when invalid value', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('If-Range', 'foo') + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('when "options" is specified', async function (t: TestContext) { + await t.test('should support start/end', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 3, end: 5 })) + .get('/nums.txt') + .expect(200, '456', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should adjust too large end', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 3, end: 90 })) + .get('/nums.txt') + .expect(200, '456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support start/end with Range request', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=-2') + .expect(206, '23', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support start/end with unsatisfiable Range request', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=5-9') + .expect('Content-Range', 'bytes */3') + .expect(416, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('file type', async function (t: TestContext) { + t.plan(6) + + const { promise, resolve } = withResolvers() + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + t.assert.strictEqual(type, 'file') + t.assert.ok((metadata as any).path) + t.assert.ok((metadata as any).stat) + t.assert.ok(!(metadata as any).error) + t.assert.ok(!(metadata as any).requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('directory type', async function (t: TestContext) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + t.assert.strictEqual(type, 'directory') + t.assert.ok((metadata as any).path) + t.assert.ok(!(metadata as any).stat) + t.assert.ok(!(metadata as any).error) + t.assert.ok((metadata as any).requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('error type', async function (t: TestContext) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + t.assert.strictEqual(type, 'error') + t.assert.ok(!(metadata as any).path) + t.assert.ok(!(metadata as any).stat) + t.assert.ok((metadata as any).error) + t.assert.ok(!(metadata as any).requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('custom directory index view', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + if (type === 'directory') { + const list = await readdir(metadata.path) + res.writeHead(200, { 'Content-Type': 'text/plain; charset=UTF-8' }) + res.end(list.join('\n') + '\n') + } else { + res.writeHead(statusCode, headers) + stream.pipe(res) + } + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/pets') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, '.hidden\nindex.html\n', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('serving from a root directory with custom error-handling', async function (t: TestContext) { + t.plan(3) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + switch (type) { + case 'directory': { + res.writeHead(301, { + Location: metadata.requestPath + '/' + }) + res.end('Redirecting to ' + metadata.requestPath + '/') + break + } + case 'error': { + res.writeHead((metadata as any).error.status ?? 500, {}) + res.end(metadata.error.message) + break + } + default: { + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + res.writeHead(statusCode, headers) + stream.pipe(res) + } + } + }) + + const promises = [] + { + const { promise, resolve } = withResolvers() + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + promises.push(promise) + } + + { + const { promise, resolve } = withResolvers() + request(app) + .get('/not-exists') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + promises.push(promise) + } + + { + const { promise, resolve } = withResolvers() + request(app) + .get('/pets/index.html') + .expect('Content-Disposition', 'attachment') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + promises.push(promise) + } + + await Promise.all(promises) + }) +}) diff --git a/packages/abstract-send/test/utils.ts b/packages/abstract-send/test/utils.ts new file mode 100644 index 00000000..136ccca3 --- /dev/null +++ b/packages/abstract-send/test/utils.ts @@ -0,0 +1,39 @@ +import http, { IncomingMessage, ServerResponse } from 'node:http' +import { TestContext } from 'node:test' +import { send } from '../lib' +import { SendOptions } from '../lib/options' + +export function createServer (options: SendOptions, fn?: (request: IncomingMessage, response : ServerResponse) => void) { + return http.createServer(async function (request, response) { + try { + fn && fn(request, response) + const { statusCode, headers, stream } = await send(request, request.url as string, options) + response.writeHead(statusCode, headers) + stream.pipe(response) + } catch (err) { + response.statusCode = 500 + response.end(String(err)) + } + }) +} + +export function shouldNotHaveHeader (header: string, t: TestContext) { + return function (response: any) { + t.assert.ok(!(header.toLowerCase() in response.headers), 'should not have header ' + header) + } +} + +export function shouldNotHaveBody (t: TestContext) { + return function (response: any) { + t.assert.ok(response.text === '' || response.text === undefined) + } +} + +export function withResolvers () { + const promise: { promise: Promise, resolve: () => void, reject: () => void } = {} as any + promise.promise = new Promise((resolve, reject) => { + promise.resolve = resolve as any + promise.reject = reject as any + }) + return promise +} diff --git a/packages/abstract-send/tsconfig.cjs.json b/packages/abstract-send/tsconfig.cjs.json new file mode 100644 index 00000000..97d519dc --- /dev/null +++ b/packages/abstract-send/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["lib/**/*"] +} diff --git a/packages/abstract-send/tsconfig.json b/packages/abstract-send/tsconfig.json new file mode 100644 index 00000000..09fddb13 --- /dev/null +++ b/packages/abstract-send/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "CommonJS", + "target": "ES2018", + "moduleResolution": "Node", + + "resolveJsonModule": true, + + "removeComments": true, + "preserveConstEnums": true, + + "sourceMap": true, + + "declaration": true, + + "strict": true, + "noImplicitAny": true, + "noImplicitThis": true, + + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["lib/**/*", "test/**/*", "fast-decode-uri-component.d.ts"], + "exclude": ["node_modules"], + "ts-node": { + "files": true + } +} diff --git a/packages/abstract-send/tsconfig.mjs.json b/packages/abstract-send/tsconfig.mjs.json new file mode 100644 index 00000000..c119ce6b --- /dev/null +++ b/packages/abstract-send/tsconfig.mjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "outDir": "lib/mjs" + }, + "include": ["lib/**/*"], + "tsc-alias": { + "resolveFullPaths": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39de7ce6..cb071d20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,67 @@ importers: .: {} + packages/abstract-send: + dependencies: + '@lukeed/ms': + specifier: ^2.0.2 + version: 2.0.2 + escape-html: + specifier: ^1.0.3 + version: 1.0.3 + fast-decode-uri-component: + specifier: ^1.0.1 + version: 1.0.1 + http-errors: + specifier: ^2.0.0 + version: 2.0.0 + mime: + specifier: ^4.0.4 + version: 4.0.4 + devDependencies: + '@kakang/unit': + specifier: workspace:^ + version: link:../unit + '@types/escape-html': + specifier: ^1.0.4 + version: 1.0.4 + '@types/http-errors': + specifier: ^2.0.4 + version: 2.0.4 + '@types/node': + specifier: ^22.5.4 + version: 22.5.4 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + c8: + specifier: ^10.1.2 + version: 10.1.2 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ^9.9.1 + version: 9.9.1 + neostandard: + specifier: ^0.11.4 + version: 0.11.4(eslint@9.9.1)(typescript@5.5.4) + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + supertest: + specifier: ^6.0.0 + version: 6.3.4 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.5.4)(typescript@5.5.4) + tsc-alias: + specifier: ^1.8.10 + version: 1.8.10 + typescript: + specifier: ~5.5.4 + version: 5.5.4 + packages/crypto: devDependencies: '@kakang/unit': @@ -291,6 +352,10 @@ packages: resolution: {integrity: sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.9.1': + resolution: {integrity: sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.4': resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -331,6 +396,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@mongodb-js/saslprep@1.1.7': resolution: {integrity: sha512-dCHW/oEX0KJ4NjDULBo3JiOaK5+6axtpBbS+ao2ZInoAL9/YRQLhXzSNAFz7hP4nzLkIqsfYAK/PDE3+XHny0Q==} @@ -391,21 +460,39 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/escape-html@1.0.4': + resolution: {integrity: sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==} + '@types/eslint@9.6.0': resolution: {integrity: sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==} '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@22.5.4': resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.2': + resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -576,6 +663,12 @@ packages: resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -638,16 +731,26 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -692,6 +795,17 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -756,6 +870,9 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -812,6 +929,16 @@ packages: jiti: optional: true + eslint@9.9.1: + resolution: {integrity: sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + espree@10.1.0: resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -832,6 +959,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -845,6 +975,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -874,6 +1007,13 @@ packages: resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==} engines: {node: '>=14'} + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + + formidable@2.1.2: + resolution: {integrity: sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -973,9 +1113,17 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hexoid@1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} @@ -988,6 +1136,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} @@ -1190,10 +1341,32 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.7: resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mime@4.0.4: + resolution: {integrity: sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==} + engines: {node: '>=16'} + hasBin: true + minimatch@10.0.1: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} @@ -1201,6 +1374,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.4: + resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1288,6 +1465,9 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1364,6 +1544,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + queue-lit@1.5.2: resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} engines: {node: '>=12'} @@ -1438,6 +1622,9 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1461,6 +1648,10 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1499,6 +1690,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + superagent@8.1.2: + resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} + engines: {node: '>=6.4.0 <13 || >=14'} + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + + supertest@6.3.4: + resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} + engines: {node: '>=6.4.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1522,6 +1722,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@4.1.1: resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} engines: {node: '>=14'} @@ -1579,6 +1783,11 @@ packages: typescript: optional: true + typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.6.2: resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} engines: {node: '>=14.17'} @@ -1640,6 +1849,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1673,6 +1885,11 @@ snapshots: eslint: 9.10.0 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.4.0(eslint@9.9.1)': + dependencies: + eslint: 9.9.1 + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.11.0': {} '@eslint/config-array@0.18.0': @@ -1699,6 +1916,8 @@ snapshots: '@eslint/js@9.10.0': {} + '@eslint/js@9.9.1': {} + '@eslint/object-schema@2.1.4': {} '@eslint/plugin-kit@0.1.0': @@ -1736,6 +1955,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@lukeed/ms@2.0.2': {} + '@mongodb-js/saslprep@1.1.7': dependencies: sparse-bitfield: 3.0.3 @@ -1763,6 +1984,14 @@ snapshots: eslint-visitor-keys: 4.0.0 espree: 10.1.0 + '@stylistic/eslint-plugin-js@2.6.4(eslint@9.9.1)': + dependencies: + '@types/eslint': 9.6.0 + acorn: 8.12.1 + eslint: 9.9.1 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + '@stylistic/eslint-plugin-jsx@2.6.4(eslint@9.10.0)': dependencies: '@stylistic/eslint-plugin-js': 2.6.4(eslint@9.10.0) @@ -1773,11 +2002,26 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.2 + '@stylistic/eslint-plugin-jsx@2.6.4(eslint@9.9.1)': + dependencies: + '@stylistic/eslint-plugin-js': 2.6.4(eslint@9.9.1) + '@types/eslint': 9.6.0 + eslint: 9.9.1 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + estraverse: 5.3.0 + picomatch: 4.0.2 + '@stylistic/eslint-plugin-plus@2.6.4(eslint@9.10.0)': dependencies: '@types/eslint': 9.6.0 eslint: 9.10.0 + '@stylistic/eslint-plugin-plus@2.6.4(eslint@9.9.1)': + dependencies: + '@types/eslint': 9.6.0 + eslint: 9.9.1 + '@stylistic/eslint-plugin-ts@2.6.4(eslint@9.10.0)(typescript@5.6.2)': dependencies: '@stylistic/eslint-plugin-js': 2.6.4(eslint@9.10.0) @@ -1788,6 +2032,16 @@ snapshots: - supports-color - typescript + '@stylistic/eslint-plugin-ts@2.6.4(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@stylistic/eslint-plugin-js': 2.6.4(eslint@9.9.1) + '@types/eslint': 9.6.0 + '@typescript-eslint/utils': 8.2.0(eslint@9.9.1)(typescript@5.5.4) + eslint: 9.9.1 + transitivePeerDependencies: + - supports-color + - typescript + '@stylistic/eslint-plugin@2.6.4(eslint@9.10.0)(typescript@5.6.2)': dependencies: '@stylistic/eslint-plugin-js': 2.6.4(eslint@9.10.0) @@ -1800,6 +2054,18 @@ snapshots: - supports-color - typescript + '@stylistic/eslint-plugin@2.6.4(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@stylistic/eslint-plugin-js': 2.6.4(eslint@9.9.1) + '@stylistic/eslint-plugin-jsx': 2.6.4(eslint@9.9.1) + '@stylistic/eslint-plugin-plus': 2.6.4(eslint@9.9.1) + '@stylistic/eslint-plugin-ts': 2.6.4(eslint@9.9.1)(typescript@5.5.4) + '@types/eslint': 9.6.0 + eslint: 9.9.1 + transitivePeerDependencies: + - supports-color + - typescript + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -1808,6 +2074,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/cookiejar@2.1.5': {} + + '@types/escape-html@1.0.4': {} + '@types/eslint@9.6.0': dependencies: '@types/estree': 1.0.5 @@ -1815,14 +2085,30 @@ snapshots: '@types/estree@1.0.5': {} + '@types/http-errors@2.0.4': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/json-schema@7.0.15': {} + '@types/methods@1.1.4': {} + '@types/node@22.5.4': dependencies: undici-types: 6.19.6 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.5.4 + form-data: 4.0.0 + + '@types/supertest@6.0.2': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -1847,6 +2133,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.0.0(@typescript-eslint/parser@8.0.0(eslint@9.9.1)(typescript@5.5.4))(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@eslint-community/regexpp': 4.11.0 + '@typescript-eslint/parser': 8.0.0(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.0.0 + '@typescript-eslint/type-utils': 8.0.0(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/utils': 8.0.0(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.0.0 + eslint: 9.9.1 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.0.0(eslint@9.10.0)(typescript@5.6.2)': dependencies: '@typescript-eslint/scope-manager': 8.0.0 @@ -1860,6 +2164,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.0.0(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@typescript-eslint/scope-manager': 8.0.0 + '@typescript-eslint/types': 8.0.0 + '@typescript-eslint/typescript-estree': 8.0.0(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.0.0 + debug: 4.3.5 + eslint: 9.9.1 + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.0.0': dependencies: '@typescript-eslint/types': 8.0.0 @@ -1882,10 +2199,37 @@ snapshots: - eslint - supports-color + '@typescript-eslint/type-utils@8.0.0(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@typescript-eslint/typescript-estree': 8.0.0(typescript@5.5.4) + '@typescript-eslint/utils': 8.0.0(eslint@9.9.1)(typescript@5.5.4) + debug: 4.3.5 + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - eslint + - supports-color + '@typescript-eslint/types@8.0.0': {} '@typescript-eslint/types@8.2.0': {} + '@typescript-eslint/typescript-estree@8.0.0(typescript@5.5.4)': + dependencies: + '@typescript-eslint/types': 8.0.0 + '@typescript-eslint/visitor-keys': 8.0.0 + debug: 4.3.5 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.2 + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.0.0(typescript@5.6.2)': dependencies: '@typescript-eslint/types': 8.0.0 @@ -1901,6 +2245,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.2.0(typescript@5.5.4)': + dependencies: + '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/visitor-keys': 8.2.0 + debug: 4.3.5 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.2 + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.2.0(typescript@5.6.2)': dependencies: '@typescript-eslint/types': 8.2.0 @@ -1927,6 +2286,17 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@8.0.0(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) + '@typescript-eslint/scope-manager': 8.0.0 + '@typescript-eslint/types': 8.0.0 + '@typescript-eslint/typescript-estree': 8.0.0(typescript@5.5.4) + eslint: 9.9.1 + transitivePeerDependencies: + - supports-color + - typescript + '@typescript-eslint/utils@8.2.0(eslint@9.10.0)(typescript@5.6.2)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0) @@ -1938,6 +2308,17 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@8.2.0(eslint@9.9.1)(typescript@5.5.4)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) + '@typescript-eslint/scope-manager': 8.2.0 + '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) + eslint: 9.9.1 + transitivePeerDependencies: + - supports-color + - typescript + '@typescript-eslint/visitor-keys@8.0.0': dependencies: '@typescript-eslint/types': 8.0.0 @@ -2044,6 +2425,10 @@ snapshots: is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 + asap@2.0.6: {} + + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -2120,12 +2505,20 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@9.5.0: {} + component-emitter@1.3.1: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} + cookiejar@2.1.4: {} + create-require@1.1.1: {} cross-env@7.0.3: @@ -2174,6 +2567,15 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@4.0.2: {} dir-glob@3.0.1: @@ -2291,6 +2693,8 @@ snapshots: escalade@3.1.2: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-compat-utils@0.5.1(eslint@9.10.0): @@ -2298,6 +2702,11 @@ snapshots: eslint: 9.10.0 semver: 7.6.2 + eslint-compat-utils@0.5.1(eslint@9.9.1): + dependencies: + eslint: 9.9.1 + semver: 7.6.2 + eslint-plugin-es-x@7.8.0(eslint@9.10.0): dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0) @@ -2305,6 +2714,13 @@ snapshots: eslint: 9.10.0 eslint-compat-utils: 0.5.1(eslint@9.10.0) + eslint-plugin-es-x@7.8.0(eslint@9.9.1): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) + '@eslint-community/regexpp': 4.11.0 + eslint: 9.9.1 + eslint-compat-utils: 0.5.1(eslint@9.9.1) + eslint-plugin-n@17.10.2(eslint@9.10.0): dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0) @@ -2317,10 +2733,26 @@ snapshots: minimatch: 9.0.5 semver: 7.6.2 + eslint-plugin-n@17.10.2(eslint@9.9.1): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) + enhanced-resolve: 5.17.0 + eslint: 9.9.1 + eslint-plugin-es-x: 7.8.0(eslint@9.9.1) + get-tsconfig: 4.7.5 + globals: 15.9.0 + ignore: 5.3.1 + minimatch: 9.0.5 + semver: 7.6.2 + eslint-plugin-promise@7.1.0(eslint@9.10.0): dependencies: eslint: 9.10.0 + eslint-plugin-promise@7.1.0(eslint@9.9.1): + dependencies: + eslint: 9.9.1 + eslint-plugin-react@7.35.0(eslint@9.10.0): dependencies: array-includes: 3.1.8 @@ -2343,6 +2775,28 @@ snapshots: string.prototype.matchall: 4.0.11 string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.35.0(eslint@9.9.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.0.19 + eslint: 9.9.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.8 + object.fromentries: 2.0.8 + object.values: 1.2.0 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.11 + string.prototype.repeat: 1.0.0 + eslint-scope@8.0.2: dependencies: esrecurse: 4.3.0 @@ -2391,6 +2845,45 @@ snapshots: transitivePeerDependencies: - supports-color + eslint@9.9.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) + '@eslint-community/regexpp': 4.11.0 + '@eslint/config-array': 0.18.0 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.9.1 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.0 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.5 + escape-string-regexp: 4.0.0 + eslint-scope: 8.0.2 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + espree@10.1.0: dependencies: acorn: 8.12.1 @@ -2409,6 +2902,8 @@ snapshots: esutils@2.0.3: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.2: @@ -2423,6 +2918,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -2456,6 +2953,19 @@ snapshots: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + formidable@2.1.2: + dependencies: + dezalgo: 1.0.4 + hexoid: 1.0.0 + once: 1.4.0 + qs: 6.13.0 + fsevents@2.3.3: optional: true @@ -2562,8 +3072,18 @@ snapshots: dependencies: function-bind: 1.1.2 + hexoid@1.0.0: {} + html-escaper@2.0.2: {} + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + ignore@5.3.1: {} import-fresh@3.3.0: @@ -2573,6 +3093,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.0.7: dependencies: es-errors: 1.3.0 @@ -2766,11 +3288,23 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.7: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mime@4.0.4: {} + minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 @@ -2779,6 +3313,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@9.0.4: + dependencies: + brace-expansion: 2.0.1 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -2819,6 +3357,23 @@ snapshots: - supports-color - typescript + neostandard@0.11.4(eslint@9.9.1)(typescript@5.5.4): + dependencies: + '@humanwhocodes/gitignore-to-minimatch': 1.0.2 + '@stylistic/eslint-plugin': 2.6.4(eslint@9.9.1)(typescript@5.5.4) + '@types/eslint': 9.6.0 + eslint: 9.9.1 + eslint-plugin-n: 17.10.2(eslint@9.9.1) + eslint-plugin-promise: 7.1.0(eslint@9.9.1) + eslint-plugin-react: 7.35.0(eslint@9.9.1) + find-up: 5.0.0 + globals: 15.9.0 + peowly: 1.3.2 + typescript-eslint: 8.0.0(eslint@9.9.1)(typescript@5.5.4) + transitivePeerDependencies: + - supports-color + - typescript + normalize-path@3.0.0: {} object-assign@4.1.1: {} @@ -2853,6 +3408,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2918,6 +3477,10 @@ snapshots: punycode@2.3.1: {} + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + queue-lit@1.5.2: {} queue-microtask@1.2.3: {} @@ -3001,6 +3564,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3022,6 +3587,8 @@ snapshots: dependencies: memory-pager: 1.5.0 + statuses@2.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3083,6 +3650,28 @@ snapshots: strip-json-comments@3.1.1: {} + superagent@8.1.2: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.3.5 + fast-safe-stringify: 2.1.1 + form-data: 4.0.0 + formidable: 2.1.2 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.13.0 + semver: 7.6.2 + transitivePeerDependencies: + - supports-color + + supertest@6.3.4: + dependencies: + methods: 1.1.2 + superagent: 8.1.2 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3095,7 +3684,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.4.3 - minimatch: 9.0.5 + minimatch: 9.0.4 text-table@0.2.0: {} @@ -3103,14 +3692,38 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tr46@4.1.1: dependencies: punycode: 2.3.1 + ts-api-utils@1.3.0(typescript@5.5.4): + dependencies: + typescript: 5.5.4 + ts-api-utils@1.3.0(typescript@5.6.2): dependencies: typescript: 5.6.2 + ts-node@10.9.2(@types/node@22.5.4)(typescript@5.5.4): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.5.4 + acorn: 8.12.0 + acorn-walk: 8.3.3 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.5.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + ts-node@10.9.2(@types/node@22.5.4)(typescript@5.6.2): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -3185,6 +3798,19 @@ snapshots: - eslint - supports-color + typescript-eslint@8.0.0(eslint@9.9.1)(typescript@5.5.4): + dependencies: + '@typescript-eslint/eslint-plugin': 8.0.0(@typescript-eslint/parser@8.0.0(eslint@9.9.1)(typescript@5.5.4))(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/parser': 8.0.0(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/utils': 8.0.0(eslint@9.9.1)(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - eslint + - supports-color + + typescript@5.5.4: {} + typescript@5.6.2: {} unbox-primitive@1.0.2: @@ -3271,6 +3897,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {}