From 018c7e1b8eb6575d16d1b58d32caef39da2191bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aura=20Rom=C3=A1n?= Date: Tue, 27 Aug 2024 11:33:10 +0200 Subject: [PATCH] refactor: remove media parsers Added `ApiRequest#asWeb()` Added `ApiRequest#readBody()` Added `ApiRequest#readBodyArrayBuffer()` Added `ApiRequest#readBodyBlob()` Added `ApiRequest#readBodyFormData()` Added `ApiRequest#readBodyJson()` Added `ApiRequest#readBodyText()` Added `ApiRequest#readValidatedBody()` Added `ApiRequest#readValidatedBodyFormData()` Added `ApiRequest#readValidatedBodyJson()` Added `ApiRequest#readValidatedBodyText()` Added `'QUERY'` to the list of method names Fixed issue in `Server#disconnect()` BREAKING CHANGE: Removed `MediaParser` BREAKING CHANGE: Removed `MediaParserStore` BREAKING CHANGE: Removed `Route#acceptedContentMimeTypes` BREAKING CHANGE: Removed `ApiRequest#body`, use the new methods instead --- packages/api/package.json | 1 + packages/api/src/index.ts | 5 - .../api/src/lib/structures/Augmentations.d.ts | 2 - .../api/src/lib/structures/MediaParser.ts | 119 ----------- .../src/lib/structures/MediaParserStore.ts | 21 -- packages/api/src/lib/structures/Route.ts | 19 +- .../api/src/lib/structures/api/ApiRequest.ts | 142 ++++++++++++- packages/api/src/lib/structures/http/Auth.ts | 4 +- .../src/lib/structures/http/HttpMethods.ts | 1 + .../api/src/lib/structures/http/Server.ts | 18 +- .../src/lib/structures/router/RouterRoot.ts | 1 - .../lib/utils/_body/RequestHeadersProxy.ts | 108 ++++++++++ .../api/src/lib/utils/_body/RequestProxy.ts | 135 ++++++++++++ .../src/lib/utils/_body/RequestURLProxy.ts | 192 ++++++++++++++++++ packages/api/src/lib/utils/constants.ts | 1 + packages/api/src/listeners/_load.ts | 2 +- packages/api/src/mediaParsers/_load.ts | 11 - .../mediaParsers/applicationFormUrlEncoded.ts | 14 -- .../api/src/mediaParsers/applicationJson.ts | 13 -- packages/api/src/mediaParsers/textPlain.ts | 13 -- packages/api/src/middlewares/_load.ts | 2 +- packages/api/src/middlewares/body.ts | 22 +- packages/api/src/register.ts | 6 +- packages/api/src/routes/_load.ts | 2 +- .../api/src/routes/oauth/callback.post.ts | 2 +- yarn.lock | 8 + 26 files changed, 594 insertions(+), 270 deletions(-) delete mode 100644 packages/api/src/lib/structures/MediaParser.ts delete mode 100644 packages/api/src/lib/structures/MediaParserStore.ts create mode 100644 packages/api/src/lib/utils/_body/RequestHeadersProxy.ts create mode 100644 packages/api/src/lib/utils/_body/RequestProxy.ts create mode 100644 packages/api/src/lib/utils/_body/RequestURLProxy.ts create mode 100644 packages/api/src/lib/utils/constants.ts delete mode 100644 packages/api/src/mediaParsers/_load.ts delete mode 100644 packages/api/src/mediaParsers/applicationFormUrlEncoded.ts delete mode 100644 packages/api/src/mediaParsers/applicationJson.ts delete mode 100644 packages/api/src/mediaParsers/textPlain.ts diff --git a/packages/api/package.json b/packages/api/package.json index 3129d1c8a..829e10c6a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -53,6 +53,7 @@ "dependencies": { "@types/ws": "^8.5.12", "@vladfrangu/async_event_emitter": "2.4.6", + "cookie-es": "^1.2.2", "tldts": "^6.1.41", "undici": "^6.19.8" }, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 338d38048..86e0967dd 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,5 +1,4 @@ import type { Server, ServerOptions } from './lib/structures/http/Server'; -import type { MediaParserStore } from './lib/structures/MediaParserStore'; import type { MiddlewareStore } from './lib/structures/MiddlewareStore'; import type { RouteStore } from './lib/structures/RouteStore'; @@ -10,8 +9,6 @@ export * from './lib/structures/http/Auth'; export * from './lib/structures/http/HttpCodes'; export * from './lib/structures/http/HttpMethods'; export * from './lib/structures/http/Server'; -export * from './lib/structures/MediaParser'; -export * from './lib/structures/MediaParserStore'; export * from './lib/structures/Middleware'; export * from './lib/structures/MiddlewareStore'; export * from './lib/structures/Route'; @@ -22,7 +19,6 @@ export * from './lib/structures/RouteStore'; export type * from './lib/utils/MimeType'; export { loadListeners } from './listeners/_load'; -export { loadMediaParsers } from './mediaParsers/_load'; export { loadMiddlewares } from './middlewares/_load'; export { loadRoutes } from './routes/_load'; @@ -39,7 +35,6 @@ declare module 'discord.js' { declare module '@sapphire/pieces' { interface StoreRegistryEntries { routes: RouteStore; - mediaParsers: MediaParserStore; middlewares: MiddlewareStore; } diff --git a/packages/api/src/lib/structures/Augmentations.d.ts b/packages/api/src/lib/structures/Augmentations.d.ts index 2e425ded5..70117a587 100644 --- a/packages/api/src/lib/structures/Augmentations.d.ts +++ b/packages/api/src/lib/structures/Augmentations.d.ts @@ -6,7 +6,6 @@ import type { MiddlewareStore, RouteStore } from '../..'; import type { Server, ServerOptions } from './http/Server'; -import type { MediaParserStore } from './MediaParserStore'; declare module 'discord.js' { export interface Client { @@ -21,7 +20,6 @@ declare module 'discord.js' { declare module '@sapphire/framework' { interface StoreRegistryEntries { routes: RouteStore; - mediaParsers: MediaParserStore; middlewares: MiddlewareStore; } } diff --git a/packages/api/src/lib/structures/MediaParser.ts b/packages/api/src/lib/structures/MediaParser.ts deleted file mode 100644 index 0144f988f..000000000 --- a/packages/api/src/lib/structures/MediaParser.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Piece } from '@sapphire/pieces'; -import type { Awaitable } from '@sapphire/utilities'; -import { createBrotliDecompress, createGunzip, createInflate, type Gunzip } from 'zlib'; -import type { MimeType } from '../utils/MimeType'; -import type { ApiRequest } from './api/ApiRequest'; -import type { Route } from './Route'; - -/** - * A media parser - * @since 1.3.0 - */ -export abstract class MediaParser extends Piece { - public constructor(context: MediaParser.LoaderContext, options: Options = {} as Options) { - super(context, options); - } - - /** - * Parses the body data from an API request. - * @since 1.3.0 - */ - public abstract run(request: MediaParser.Request): Awaitable; - - /** - * Checks if a route accepts the media type from this parser. - * @since 1.3.0 - * @param route The route to be checked. - */ - public accepts(route: Route): boolean { - return route.acceptedContentMimeTypes === null || route.acceptedContentMimeTypes.includes(this.name as MimeType); - } - - /** - * Reads the content body as a string, this is useful for parsing/reading plain-text data. - * @since 1.3.0 - * @param request The request to read the body from. - */ - protected async readString(request: MediaParser.Request): Promise { - const stream = this.contentStream(request); - if (stream === null) return ''; - - let body = ''; - for await (const chunk of stream) body += chunk; - - return body; - } - - /** - * Reads the content body as a buffer, this is useful for parsing/reading binary data. - * @since 1.3.0 - * @param request The request to read the body from. - */ - protected async readBuffer(request: MediaParser.Request): Promise { - const stream = this.contentStream(request); - if (stream === null) return Buffer.alloc(0); - - const bodies: Buffer[] = []; - for await (const chunk of stream) bodies.push(chunk); - - return Buffer.concat(bodies); - } - - /** - * Reads the content stream from a request, piping the data through a transformer stream. - * @since 1.3.0 - * @param request The request to read the body from. - */ - protected contentStream(request: MediaParser.Request): MediaParser.Request | Gunzip | null { - switch ((request.headers['content-encoding'] ?? 'identity').toLowerCase()) { - // RFC 7230 4.2.2: - // - // The "deflate" coding is a "zlib" data format (RFC 1950) containing a "deflate" compressed data stream - // (RFC 1951) that uses a combination of the Lempel-Ziv (LZ77) compression algorithm and Huffman coding. - case 'deflate': { - const stream = createInflate(); - request.pipe(stream); - return stream; - } - - // RFC 7230 4.2.3 - // - // The "gzip" coding is an LZ77 coding with a 32-bit Cyclic Redundancy Check (CRC) that is commonly produced - // by the gzip file compression program (RFC 1952). - case 'x-gzip': - case 'gzip': { - const stream = createGunzip(); - request.pipe(stream); - return stream; - } - - // RFC 7932 - // - // A format using the Brotli algorithm. - case 'br': { - const stream = createBrotliDecompress(); - request.pipe(stream); - return stream; - } - - // An "identity" token is used as a synonym for "no encoding" in order to communicate when no encoding is - // preferred. - case 'identity': { - return request; - } - } - - return null; - } -} - -export namespace MediaParser { - /** @deprecated Use {@linkcode LoaderContext} instead. */ - export type Context = LoaderContext; - export type LoaderContext = Piece.LoaderContext<'mediaParsers'>; - export type Options = Piece.Options; - export type JSON = Piece.JSON; - export type LocationJSON = Piece.LocationJSON; - - export type Request = ApiRequest; -} diff --git a/packages/api/src/lib/structures/MediaParserStore.ts b/packages/api/src/lib/structures/MediaParserStore.ts deleted file mode 100644 index a6a66b69d..000000000 --- a/packages/api/src/lib/structures/MediaParserStore.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Store } from '@sapphire/pieces'; -import { MediaParser } from './MediaParser'; - -/** - * @since 1.3.0 - */ -export class MediaParserStore extends Store { - public constructor() { - super(MediaParser, { name: 'mediaParsers' }); - } - - /** - * Parses a content type by getting the relevant information inside. - * @since 1.3.0 - * @param contentType The content type to parse. - */ - public parseContentType(contentType: string): string { - const index = contentType.indexOf(';'); - return index === -1 ? contentType : contentType.slice(0, index); - } -} diff --git a/packages/api/src/lib/structures/Route.ts b/packages/api/src/lib/structures/Route.ts index 87c3ced71..d60803550 100644 --- a/packages/api/src/lib/structures/Route.ts +++ b/packages/api/src/lib/structures/Route.ts @@ -1,6 +1,5 @@ import { Piece } from '@sapphire/pieces'; import { isNullish, type Awaitable } from '@sapphire/utilities'; -import type { MimeType } from '../utils/MimeType'; import type { ApiRequest } from './api/ApiRequest'; import type { ApiResponse } from './api/ApiResponse'; import type { MethodName } from './http/HttpMethods'; @@ -49,11 +48,6 @@ export abstract class Route exten */ public readonly maximumBodyLength: number; - /** - * The accepted content types. - */ - public readonly acceptedContentMimeTypes: readonly MimeType[] | null; - /** * The path this route represents. */ @@ -78,13 +72,12 @@ export abstract class Route exten if (!isNullish(implied)) { const lastIndex = path.length - 1; path[lastIndex] = path[lastIndex].slice(0, path[lastIndex].length - implied.length - 1); - methods.add(implied as MethodName); + methods.add(implied); } this.path = path; this.methods = methods; this.maximumBodyLength = options.maximumBodyLength ?? api.maximumBodyLength ?? 1024 * 1024 * 50; - this.acceptedContentMimeTypes = options.acceptedContentMimeTypes ?? api.acceptedContentMimeTypes ?? null; } public abstract run(request: Route.Request, response: Route.Response): Awaitable; @@ -118,14 +111,6 @@ export interface RouteOptions extends Piece.Options { */ maximumBodyLength?: number; - /** - * The accepted content types for this route. If set to null, the route will accept any data. - * @since 1.3.0 - * - * @defaultValue this.context.server.options.acceptedContentMimeTypes ?? null - */ - acceptedContentMimeTypes?: readonly MimeType[] | null; - /** * The methods this route accepts. * @since 7.0.0 @@ -137,7 +122,7 @@ export interface RouteOptions extends Piece.Options { export namespace Route { /** @deprecated Use {@linkcode LoaderContext} instead. */ - export type Context = LoaderContext; + export type Context = LoaderContext; // NOSONAR export type LoaderContext = Piece.LoaderContext<'routes'>; export type Options = RouteOptions; export type JSON = Piece.JSON; diff --git a/packages/api/src/lib/structures/api/ApiRequest.ts b/packages/api/src/lib/structures/api/ApiRequest.ts index bbcd974c4..2979a4439 100644 --- a/packages/api/src/lib/structures/api/ApiRequest.ts +++ b/packages/api/src/lib/structures/api/ApiRequest.ts @@ -1,4 +1,7 @@ +import { isNullishOrEmpty } from '@sapphire/utilities'; import { IncomingMessage } from 'node:http'; +import type { MimeType } from '../../utils/MimeType'; +import { RequestProxy } from '../../utils/_body/RequestProxy'; import type { Route } from '../Route'; import type { AuthData } from '../http/Auth'; import type { RouterNode } from '../router/RouterNode'; @@ -14,11 +17,6 @@ export class ApiRequest extends IncomingMessage { */ public params: Record = {}; - /** - * The body that was sent by the user. - */ - public body?: unknown; - /** * The authorization information. This field indicates three possible values: * @@ -55,4 +53,138 @@ export class ApiRequest extends IncomingMessage { * @since 7.0.0 */ public route?: Route | null; + + /** + * The response object. This field is cached to prevent multiple response + * objects from being created. + */ + #cachedRequest: Request | null = null; + + get #isFormContentType() { + const contentType = this.asWeb().headers.get('content-type') as MimeType | null; + + // If Content-Type isn't sent, we can't assume it's a form. + if (isNullishOrEmpty(contentType)) return false; + + // If the Content-Type is application/x-www-form-urlencoded or multipart/form-data, it's a form. + return contentType.startsWith('application/x-www-form-urlencoded') || contentType.startsWith('multipart/form-data'); + } + + /** + * The response object, used to validate the request's headers and body. + */ + public asWeb(): Request { + this.#cachedRequest ??= new RequestProxy(this); + return this.#cachedRequest; + } + + /** + * Reads the request body and tries to parse using JSON or form-urlencoded. + * + * @example + * ```typescript + * const body = await request.readBody(); + * ``` + * + * @returns The result of the body parsing + */ + public readBody() { + return this.#isFormContentType ? this.readBodyFormData() : this.readBodyJson(); + } + + /** + * Reads the request body as an {@link ArrayBuffer}. + * + * @returns The result of the body parsing + */ + public readBodyArrayBuffer() { + return this.asWeb().arrayBuffer(); + } + + /** + * Reads the request body as a {@link Blob}. + * + * @returns The result of the body parsing + */ + public readBodyBlob() { + return this.asWeb().blob(); + } + + /** + * Reads the request body as a {@link FormData}. + * + * @remarks + * + * This will throw an error if the content type is not one of the following: + * + * - `application/x-www-form-urlencoded` + * - `multipart/form-data` + * + * @returns The result of the body parsing + */ + public readBodyFormData() { + return this.asWeb().formData(); // NOSONAR + } + + /** + * Reads the request body as text, using {@link TextDecoder}. Afterward, it + * parses the body as JSON with {@link JSON.parse}. + * + * @returns The result of the body parsing + */ + public readBodyJson() { + return this.asWeb().json(); + } + + /** + * Reads the request body as text, using {@link TextDecoder}. + * + * @returns The result of the body parsing + */ + public readBodyText() { + return this.asWeb().text(); + } + + /** + * Identical to {@link ApiRequest.readBody}, but it validates the result. + * + * @param validator The validator function to use on the body parsing result + * @returns The validated body + */ + public readValidatedBody(validator: ValidatorFunction) { + return this.readBody().then(validator); + } + + /** + * Identical to {@link ApiRequest.readBodyFormData}, but it validates the + * result. + * + * @param validator The validator function to use on the body parsing result + * @returns The validated body + */ + public readValidatedBodyFormData(validator: ValidatorFunction) { + return this.readBodyFormData().then(validator); + } + + /** + * Identical to {@link ApiRequest.readBodyJson}, but it validates the result. + * + * @param validator The validator function to use on the body parsing result + * @returns The validated body + */ + public readValidatedBodyJson(validator: ValidatorFunction) { + return this.readBodyJson().then(validator); + } + + /** + * Identical to {@link ApiRequest.readBodyText}, but it validates the result. + * + * @param validator The validator function to use on the body parsing result + * @returns The validated body + */ + public readValidatedBodyText(validator: ValidatorFunction) { + return this.readBodyText().then(validator); + } } + +export type ValidatorFunction = (data: Data) => Type | ((data: Data) => data is Type); diff --git a/packages/api/src/lib/structures/http/Auth.ts b/packages/api/src/lib/structures/http/Auth.ts index 469c7e592..de1a91f98 100644 --- a/packages/api/src/lib/structures/http/Auth.ts +++ b/packages/api/src/lib/structures/http/Auth.ts @@ -47,7 +47,7 @@ export class Auth { #secret: string; private constructor(options: ServerOptionsAuth) { - this.id = options.id as Snowflake; + this.id = options.id; this.cookie = options.cookie ?? 'SAPPHIRE_AUTH'; this.scopes = options.scopes ?? [OAuth2Scopes.Identify]; this.redirect = options.redirect; @@ -178,7 +178,7 @@ export interface ServerOptionsAuth { * The client's application id, this can be retrieved in Discord Developer Portal at https://discord.com/developers/applications. * @since 1.0.0 */ - id: string; + id: Snowflake; /** * The name for the cookie, this will be used to identify a Secure HttpOnly cookie. diff --git a/packages/api/src/lib/structures/http/HttpMethods.ts b/packages/api/src/lib/structures/http/HttpMethods.ts index be7f9fcf2..29173c289 100644 --- a/packages/api/src/lib/structures/http/HttpMethods.ts +++ b/packages/api/src/lib/structures/http/HttpMethods.ts @@ -25,6 +25,7 @@ export const MethodNames = [ 'PROPPATCH', 'PURGE', 'PUT', + 'QUERY', 'REBIND', 'REPORT', 'SEARCH', diff --git a/packages/api/src/lib/structures/http/Server.ts b/packages/api/src/lib/structures/http/Server.ts index 9bb62d4eb..9eac351c2 100644 --- a/packages/api/src/lib/structures/http/Server.ts +++ b/packages/api/src/lib/structures/http/Server.ts @@ -2,8 +2,6 @@ import { container } from '@sapphire/pieces'; import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter'; import { Server as HttpServer, createServer as httpCreateServer, type ServerOptions as HttpOptions } from 'node:http'; import type { ListenOptions } from 'node:net'; -import type { MimeType } from '../../utils/MimeType'; -import { MediaParserStore } from '../MediaParserStore'; import { MiddlewareStore } from '../MiddlewareStore'; import type { Route } from '../Route'; import { RouteStore } from '../RouteStore'; @@ -52,12 +50,6 @@ export class Server extends AsyncEventEmitter { */ public readonly middlewares: MiddlewareStore; - /** - * The media parsers this server holds. - * @since 1.3.0 - */ - public readonly mediaParsers: MediaParserStore; - /** * The authentication system. * @since 1.0.0 @@ -93,7 +85,6 @@ export class Server extends AsyncEventEmitter { }); this.routes = new RouteStore(); this.middlewares = new MiddlewareStore(); - this.mediaParsers = new MediaParserStore(); this.auth = Auth.create(auth); this.server.on('error', this.emit.bind(this, ServerEvent.Error)); this.server.on('request', this.emit.bind(this, ServerEvent.Request)); @@ -136,7 +127,7 @@ export class Server extends AsyncEventEmitter { public disconnect() { return new Promise((resolve, reject) => { - this.server.close((error) => (error ? resolve() : reject(error))); + this.server.close((error) => (error ? reject(error) : resolve())); }); } } @@ -206,13 +197,6 @@ export interface ServerOptions { */ maximumBodyLength?: number; - /** - * The accepted content types for this route. If set to null, the route will accept any data. - * @since 1.3.0 - * @default null - */ - acceptedContentMimeTypes?: MimeType[] | null; - /** * The HTTP server options. * @since 1.0.0 diff --git a/packages/api/src/lib/structures/router/RouterRoot.ts b/packages/api/src/lib/structures/router/RouterRoot.ts index cbe186233..fd2e43b11 100644 --- a/packages/api/src/lib/structures/router/RouterRoot.ts +++ b/packages/api/src/lib/structures/router/RouterRoot.ts @@ -56,7 +56,6 @@ export class RouterRoot extends RouterBranch { if (part.length) { parts.push(part); - part = ''; } return parts; diff --git a/packages/api/src/lib/utils/_body/RequestHeadersProxy.ts b/packages/api/src/lib/utils/_body/RequestHeadersProxy.ts new file mode 100644 index 000000000..0e042dd18 --- /dev/null +++ b/packages/api/src/lib/utils/_body/RequestHeadersProxy.ts @@ -0,0 +1,108 @@ +import { isNullishOrEmpty } from '@sapphire/utilities'; +import { splitSetCookieString } from 'cookie-es'; +import type { Headers, SpecIterableIterator } from 'undici-types'; +import type { ApiRequest } from '../../structures/api/ApiRequest'; +import { NodeUtilInspectSymbol } from '../constants'; + +export class RequestHeadersProxy implements Headers { + private readonly request: ApiRequest; + + public constructor(request: ApiRequest) { + this.request = request; + } + + public append(name: string, value: string): void { + const { headers } = this.request; + const current = headers[name]; + if (current) { + if (Array.isArray(current)) { + current.push(value); + } else { + headers[name] = [current, value]; + } + } else { + headers[name] = value; + } + } + + public delete(name: string): void { + this.request.headers[name] = undefined; + } + + public get(name: string): string | null { + return normalizeValue(this.request.headers[name]); + } + + public has(name: string): boolean { + return !isNullishOrEmpty(this.request.headers[name]); + } + + public set(name: string, value: string): void { + this.request.headers[name] = value; + } + + public getSetCookie(): string[] { + const setCookie = this.get('set-cookie'); + return setCookie === null ? [] : splitSetCookieString(setCookie); + } + + public forEach(callbackfn: (value: string, key: string, iterable: Headers) => void, thisArg?: unknown): void { + for (const [key, value] of this.entries()) { + callbackfn.call(thisArg, value, key, this); + } + } + + public *keys(): SpecIterableIterator { + const { headers } = this.request; + for (const key of Object.keys(headers)) { + const value = headers[key]; + + if (!isNullishOrEmpty(value)) { + yield key; + } + } + } + + public *values(): SpecIterableIterator { + const { headers } = this.request; + for (const key of Object.keys(headers)) { + const value = headers[key]; + + if (!isNullishOrEmpty(value)) { + yield normalizeValue(value); + } + } + } + + public *entries(): SpecIterableIterator<[string, string]> { + const { headers } = this.request; + for (const key of Object.keys(headers)) { + const value = headers[key]; + + if (!isNullishOrEmpty(value)) { + yield [key, normalizeValue(value)]; + } + } + } + + public [Symbol.iterator](): SpecIterableIterator<[string, string]> { + return this.entries(); + } + + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + public get [Symbol.toStringTag]() { + return 'Headers'; + } + + public [NodeUtilInspectSymbol]() { + return Object.fromEntries(this.entries()); + } +} + +function normalizeValue(value: string | string[] | undefined): string { + if (Array.isArray(value)) { + return value.join(', '); + } + + return String(value ?? ''); +} diff --git a/packages/api/src/lib/utils/_body/RequestProxy.ts b/packages/api/src/lib/utils/_body/RequestProxy.ts new file mode 100644 index 000000000..3106a17eb --- /dev/null +++ b/packages/api/src/lib/utils/_body/RequestProxy.ts @@ -0,0 +1,135 @@ +import { isNullish, isNullishOrEmpty } from '@sapphire/utilities'; +import { Blob } from 'node:buffer'; +import { arrayBuffer } from 'node:stream/consumers'; +import { ReadableStream } from 'node:stream/web'; +import type { + FormData, + Headers, + ReferrerPolicy, + Request, + RequestCache, + RequestCredentials, + RequestDestination, + RequestMode, + RequestRedirect +} from 'undici'; +import type { ApiRequest } from '../../structures/api/ApiRequest'; +import type { MethodName } from '../../structures/http/HttpMethods'; +import { RequestHeadersProxy } from './RequestHeadersProxy'; +import { RequestURLProxy } from './RequestURLProxy'; + +export class RequestProxy implements Request { + public cache: RequestCache = 'default'; + public credentials: RequestCredentials = 'same-origin'; + public destination: RequestDestination = ''; + public integrity: string = ''; + public keepalive: boolean = false; + + public mode: RequestMode = 'cors'; + public redirect: RequestRedirect = 'follow'; + public referrer: string = 'about:client'; + public referrerPolicy: ReferrerPolicy = ''; + + public headers: Headers; + + public bodyUsed: boolean = false; + public duplex = 'half' as const; + + readonly #request: ApiRequest; + readonly #url: RequestURLProxy; + #cachedMethod: MethodName | null = null; + #cachedHasBody: boolean | null = null; + #abortController: AbortController | null = null; + #bodyStream: ReadableStream | null = null; + + public constructor(request: ApiRequest) { + this.#request = request; + this.#url = new RequestURLProxy(request); + this.headers = new RequestHeadersProxy(request); + } + + public get url(): string { + return this.#url.href; + } + + public get method(): MethodName { + this.#cachedMethod ??= (this.#request.method?.toUpperCase() ?? 'GET') as MethodName; + return this.#cachedMethod; + } + + public get signal() { + this.#abortController ??= new AbortController(); + return this.#abortController.signal; + } + + public get body(): ReadableStream | null { + if (!this.hasBody) return null; + + this.#bodyStream ??= new ReadableStream({ + start: (controller) => { + this.#request + .on('data', (chunk) => controller.enqueue(chunk)) + .once('error', (error) => { + controller.error(error); + this.#abortController?.abort(); + }) + .once('close', () => { + this.#abortController?.abort(); + }) + .once('end', () => { + controller.close(); + }); + } + }); + + return this.#bodyStream; + } + + public async arrayBuffer(): Promise { + const { body } = this; + return isNullish(body) ? new ArrayBuffer(0) : arrayBuffer(body); + } + + public async blob(): Promise { + const arrayBuffer = await this.arrayBuffer(); + return new Blob([arrayBuffer], { + type: this.headers.get('content-type') ?? '' + }); + } + + public async formData(): Promise { + return new Response(this.body, { headers: this.headers }).formData(); // NOSONAR + } + + public async json(): Promise { + return JSON.parse(await this.text()); + } + + public async text(): Promise { + return new TextDecoder().decode(await this.arrayBuffer()); + } + + public clone(): Request { + return new RequestProxy(this.#request); + } + + private get hasBody() { + if (this.#cachedHasBody !== null) return this.#cachedHasBody; + + const contentLengthString = this.headers.get('content-length'); + const contentLength = isNullishOrEmpty(contentLengthString) ? 0 : Number(contentLengthString); + if (Number.isSafeInteger(contentLength) && contentLength > 0) { + this.#cachedHasBody = true; + return true; + } + + const transferEncoding = this.headers.get('transfer-encoding'); + if (transferEncoding?.includes('chunked')) { + this.#cachedHasBody = true; + return true; + } + + this.#cachedHasBody = false; + return false; + } +} diff --git a/packages/api/src/lib/utils/_body/RequestURLProxy.ts b/packages/api/src/lib/utils/_body/RequestURLProxy.ts new file mode 100644 index 000000000..5031fc494 --- /dev/null +++ b/packages/api/src/lib/utils/_body/RequestURLProxy.ts @@ -0,0 +1,192 @@ +import { isNullishOrEmpty } from '@sapphire/utilities'; +import type { ApiRequest } from '../../structures/api/ApiRequest'; +import { NodeUtilInspectSymbol } from '../constants'; + +export class RequestURLProxy implements URL { + public hash: string = ''; + public password: string = ''; + public username: string = ''; + + #protocol: URLProtocol | null = null; + #hostname: string | null = null; + #port: string | null = null; + + #pathname: string | null = null; + #search: string | null = null; + #searchParams: URLSearchParams | null = null; + + readonly #request: ApiRequest; + + public constructor(request: ApiRequest) { + this.#request = request; + } + + public get host(): string { + return this.#request.headers.host ?? ''; + } + + public set host(value: string) { + this.#hostname = null; + this.#port = null; + this.#request.headers.host = value; + } + + public get hostname(): string { + if (this.#hostname === null) { + const [hostname, port] = parseHost(this.#request.headers.host); + this.#hostname = hostname; + this.#port = port === -1 ? '' : String(port); + } + + return this.#hostname; + } + + public set hostname(value: string) { + this.#hostname = value; + } + + public get port(): string { + if (this.#port === null) { + const [hostname, port] = parseHost(this.#request.headers.host); + this.#hostname = hostname; + this.#port = port === -1 ? String(this.#request.socket.localPort ?? '') : String(port); + } + + return this.#port; + } + + public set port(value: string) { + this.#port = String(Number(value || '')); + } + + public get pathname(): string { + if (this.#pathname === null) { + const [pathname, search] = parsePath(this.#request.url); + this.#pathname = pathname; + this.#search ??= search; + } + + return this.#pathname; + } + + public set pathname(value: string) { + const normalized = value.startsWith('/') ? value : `/${value}`; + if (normalized === this.#pathname) return; + + this.#pathname = normalized; + this.#request.url = normalized + this.search; + } + + public get search(): string { + if (this.#search === null) { + const [pathname, search] = parsePath(this.#request.url); + this.#pathname ??= pathname; + this.#search = search; + } + + return this.#search; + } + + public set search(value: string) { + let normalized: string; + if (value === '?') { + normalized = ''; + } else if (value && !value.startsWith('?')) { + normalized = `?${value}`; + } else { + normalized = value; + } + + if (normalized === this.#search) return; + + this.#search = normalized; + this.#searchParams = null; + this.#request.url = this.pathname + normalized; + } + + public get searchParams(): URLSearchParams { + this.#searchParams ??= new URLSearchParams(this.search); + return this.#searchParams; + } + + public set searchParams(value: URLSearchParams) { + this.#searchParams = value; + this.search = value.toString(); + } + + public get protocol(): URLProtocol { + this.#protocol ??= (this.#request.socket as any).encrypted || this.#request.headers['x-forwarded-proto'] === 'https' ? 'https:' : 'http:'; + return this.#protocol; + } + + public set protocol(value: URLProtocol) { + this.#protocol = value; + } + + public get origin(): string { + return `${this.protocol}//${this.host}`; + } + + public set origin(_value: string) { + // No-op + } + + public get href(): string { + return `${this.protocol}//${this.host}${this.pathname}${this.search}`; + } + + public set href(value: string) { + const url = new URL(value); + this.#protocol = url.protocol as URLProtocol; + this.username = url.username; + this.password = url.password; + this.#hostname = url.hostname; + this.#port = url.port; + this.#pathname = url.pathname; + this.#search = url.search; + this.hash = url.hash; + } + + public toString(): string { + return this.href; + } + + public toJSON(): string { + return this.href; + } + + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + public get [Symbol.toStringTag]() { + return 'URL'; + } + + public [NodeUtilInspectSymbol]() { + return this.href; + } +} + +export type URLProtocol = 'https:' | 'http:'; + +function parsePath(input: string | undefined): [pathname: string, search: string] { + const url = (input ?? '/').replaceAll('\\', '/'); + const index = url.indexOf('?'); + if (index === -1) { + return [url, '']; + } + + return [url.slice(0, index), url.slice(index)]; +} + +function parseHost(host: string | undefined): [hostname: string, port: number] { + if (isNullishOrEmpty(host)) { + return ['localhost', -1]; + } + + const index = host.indexOf(':'); + if (index === -1) { + return [host, -1]; + } + + const port = Number(host.slice(index)); + return [host.slice(0, index), Number.isSafeInteger(port) ? port : -1]; +} diff --git a/packages/api/src/lib/utils/constants.ts b/packages/api/src/lib/utils/constants.ts new file mode 100644 index 000000000..83bbea96d --- /dev/null +++ b/packages/api/src/lib/utils/constants.ts @@ -0,0 +1 @@ +export const NodeUtilInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); diff --git a/packages/api/src/listeners/_load.ts b/packages/api/src/listeners/_load.ts index 1044b639b..0e6eac474 100644 --- a/packages/api/src/listeners/_load.ts +++ b/packages/api/src/listeners/_load.ts @@ -8,7 +8,7 @@ import { PluginListener as PluginServerRouterBranchNotFound } from './PluginServ import { PluginListener as PluginServerRouterFound } from './PluginServerRouterFound'; export function loadListeners() { - const store = 'listeners' as const; + const store = 'listeners'; void container.stores.loadPiece({ name: 'PluginRouteError', piece: PluginRouteError, store }); void container.stores.loadPiece({ name: 'PluginServerMiddlewareError', piece: PluginServerMiddlewareError, store }); void container.stores.loadPiece({ name: 'PluginServerMiddlewareSuccess', piece: PluginServerMiddlewareSuccess, store }); diff --git a/packages/api/src/mediaParsers/_load.ts b/packages/api/src/mediaParsers/_load.ts deleted file mode 100644 index f5273bb57..000000000 --- a/packages/api/src/mediaParsers/_load.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { container } from '@sapphire/pieces'; -import { PluginMediaParser as PluginApplicationFormUrlEncoded } from './applicationFormUrlEncoded'; -import { PluginMediaParser as PluginApplicationJson } from './applicationJson'; -import { PluginMediaParser as PluginTextPlain } from './textPlain'; - -export function loadMediaParsers() { - const store = 'mediaParsers' as const; - void container.stores.loadPiece({ name: 'applicationFormUrlEncoded', piece: PluginApplicationFormUrlEncoded, store }); - void container.stores.loadPiece({ name: 'applicationJson', piece: PluginApplicationJson, store }); - void container.stores.loadPiece({ name: 'textPlain', piece: PluginTextPlain, store }); -} diff --git a/packages/api/src/mediaParsers/applicationFormUrlEncoded.ts b/packages/api/src/mediaParsers/applicationFormUrlEncoded.ts deleted file mode 100644 index 48a7f382b..000000000 --- a/packages/api/src/mediaParsers/applicationFormUrlEncoded.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { URLSearchParams } from 'url'; -import { MediaParser } from '../lib/structures/MediaParser'; -import type { MimeType } from '../lib/utils/MimeType'; - -export class PluginMediaParser extends MediaParser { - public constructor(context: MediaParser.LoaderContext) { - super(context, { name: 'application/x-www-form-urlencoded' satisfies MimeType }); - } - - public override async run(request: MediaParser.Request): Promise { - const body = await this.readString(request); - return body.length === 0 ? null : Object.fromEntries(new URLSearchParams(body).entries()); - } -} diff --git a/packages/api/src/mediaParsers/applicationJson.ts b/packages/api/src/mediaParsers/applicationJson.ts deleted file mode 100644 index 55b0f0a18..000000000 --- a/packages/api/src/mediaParsers/applicationJson.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MediaParser } from '../lib/structures/MediaParser'; -import type { MimeType } from '../lib/utils/MimeType'; - -export class PluginMediaParser extends MediaParser { - public constructor(context: MediaParser.LoaderContext) { - super(context, { name: 'application/json' satisfies MimeType }); - } - - public override async run(request: MediaParser.Request): Promise { - const body = await this.readString(request); - return body.length === 0 ? null : JSON.parse(body); - } -} diff --git a/packages/api/src/mediaParsers/textPlain.ts b/packages/api/src/mediaParsers/textPlain.ts deleted file mode 100644 index ea0b07eb7..000000000 --- a/packages/api/src/mediaParsers/textPlain.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MediaParser } from '../lib/structures/MediaParser'; -import type { MimeType } from '../lib/utils/MimeType'; - -export class PluginMediaParser extends MediaParser { - public constructor(context: MediaParser.LoaderContext) { - super(context, { name: 'text/plain' satisfies MimeType }); - } - - public override async run(request: MediaParser.Request): Promise { - const body = await this.readString(request); - return body.length === 0 ? null : body; - } -} diff --git a/packages/api/src/middlewares/_load.ts b/packages/api/src/middlewares/_load.ts index 78395c648..5fffb8795 100644 --- a/packages/api/src/middlewares/_load.ts +++ b/packages/api/src/middlewares/_load.ts @@ -5,7 +5,7 @@ import { PluginMiddleware as PluginCookies } from './cookies'; import { PluginMiddleware as PluginHeaders } from './headers'; export function loadMiddlewares() { - const store = 'middlewares' as const; + const store = 'middlewares'; void container.stores.loadPiece({ name: 'auth', piece: PluginAuth, store }); void container.stores.loadPiece({ name: 'body', piece: PluginBody, store }); void container.stores.loadPiece({ name: 'cookies', piece: PluginCookies, store }); diff --git a/packages/api/src/middlewares/body.ts b/packages/api/src/middlewares/body.ts index b8ee937d9..bf83fbe08 100644 --- a/packages/api/src/middlewares/body.ts +++ b/packages/api/src/middlewares/body.ts @@ -1,16 +1,12 @@ import { HttpCodes } from '../lib/structures/http/HttpCodes'; -import type { MediaParserStore } from '../lib/structures/MediaParserStore'; import { Middleware } from '../lib/structures/Middleware'; export class PluginMiddleware extends Middleware { - private readonly mediaParsers: MediaParserStore; - public constructor(context: Middleware.LoaderContext) { super(context, { position: 20 }); - this.mediaParsers = this.container.server.mediaParsers; } - public override async run(request: Middleware.Request, response: Middleware.Response) { + public override run(request: Middleware.Request, response: Middleware.Response) { if (!request.route) return; // RFC 1341 4. @@ -26,22 +22,6 @@ export class PluginMiddleware extends Middleware { const maximumLength = request.route.maximumBodyLength; if (length > maximumLength) { response.status(HttpCodes.PayloadTooLarge).json({ error: 'Exceeded maximum content length.' }); - return; - } - - // Verify if the content type is supported by the parser: - const type = this.mediaParsers.parseContentType(contentType); - const parser = this.mediaParsers.get(type); - if (!parser || !parser.accepts(request.route)) { - response.status(HttpCodes.UnsupportedMediaType).json({ error: `Unsupported type ${type}.` }); - return; - } - - try { - // Parse the content body: - request.body = await parser.run(request); - } catch { - response.status(HttpCodes.BadRequest).json({ error: `Cannot parse ${type} data.` }); } } } diff --git a/packages/api/src/register.ts b/packages/api/src/register.ts index 81a3d66c4..e62e4fa16 100644 --- a/packages/api/src/register.ts +++ b/packages/api/src/register.ts @@ -1,8 +1,6 @@ -import './index'; - import { Plugin, postInitialization, preLogin, SapphireClient } from '@sapphire/framework'; import type { ClientOptions } from 'discord.js'; -import { loadListeners, loadMediaParsers, loadMiddlewares, loadRoutes, Server } from './index'; +import { loadListeners, loadMiddlewares, loadRoutes, Server } from './index'; /** * @since 1.0.0 @@ -15,11 +13,9 @@ export class Api extends Plugin { this.server = new Server(options.api); this.stores .register(this.server.routes) // - .register(this.server.mediaParsers) .register(this.server.middlewares); loadListeners(); - loadMediaParsers(); loadMiddlewares(); loadRoutes(); } diff --git a/packages/api/src/routes/_load.ts b/packages/api/src/routes/_load.ts index 204888d0f..1ccd21922 100644 --- a/packages/api/src/routes/_load.ts +++ b/packages/api/src/routes/_load.ts @@ -3,7 +3,7 @@ import { PluginRoute as PluginOAuthCallback } from './oauth/callback.post'; import { PluginRoute as PluginOAuthLogout } from './oauth/logout.post'; export function loadRoutes() { - const store = 'routes' as const; + const store = 'routes'; void container.stores.loadPiece({ name: 'callback', piece: PluginOAuthCallback, store }); void container.stores.loadPiece({ name: 'logout', piece: PluginOAuthLogout, store }); } diff --git a/packages/api/src/routes/oauth/callback.post.ts b/packages/api/src/routes/oauth/callback.post.ts index ab1edd467..15c95ba53 100644 --- a/packages/api/src/routes/oauth/callback.post.ts +++ b/packages/api/src/routes/oauth/callback.post.ts @@ -16,7 +16,7 @@ export class PluginRoute extends Route { } public override async run(request: Route.Request, response: Route.Response) { - const body = request.body as OAuth2BodyData; + const body = (await request.readBodyJson()) as OAuth2BodyData; if (typeof body?.code !== 'string') { return response.badRequest(); } diff --git a/yarn.lock b/yarn.lock index d01d9442e..ef0bcfd0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1342,6 +1342,7 @@ __metadata: "@types/ws": "npm:^8.5.12" "@vladfrangu/async_event_emitter": "npm:2.4.6" concurrently: "npm:^8.2.2" + cookie-es: "npm:^1.2.2" tldts: "npm:^6.1.41" tsup: "npm:^8.2.4" tsx: "npm:^4.18.0" @@ -2610,6 +2611,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^1.2.2": + version: 1.2.2 + resolution: "cookie-es@npm:1.2.2" + checksum: 10/0fd742c11caa185928e450543f84df62d4b2c1fc7b5041196b57b7db04e1c6ac6585fb40e4f579a2819efefd2d6a9cbb4d17f71240d05f4dcd8f74ae81341a20 + languageName: node + linkType: hard + "cosmiconfig-typescript-loader@npm:^5.0.0": version: 5.0.0 resolution: "cosmiconfig-typescript-loader@npm:5.0.0"