UNPKG

rjweb-server

Version:

Easy and Robust Way to create a Web Server with Many Easy-to-use Features in NodeJS

602 lines (601 loc) 24.3 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var HttpRequest_exports = {}; __export(HttpRequest_exports, { default: () => HTTPRequest, toArrayBuffer: () => toArrayBuffer }); module.exports = __toCommonJS(HttpRequest_exports); var import_statusEnum = __toESM(require("../../misc/statusEnum")); var import_parseContent = __toESM(require("../../functions/parseContent")); var import_HTMLBuilder = __toESM(require("../HTMLBuilder")); var import_Base = __toESM(require("./Base")); var import_handleCompressType = __toESM(require("../../functions/handleCompressType")); var import_path = require("path"); var import_uws2 = require("@rjweb/uws"); var import_fs = require("fs"); var import_parseContentType = __toESM(require("../../functions/parseContentType")); var import_writeHTTPMeta = __toESM(require("../../functions/writeHTTPMeta")); var import_parseKV = __toESM(require("../../functions/parseKV")); var import_getCompressMethod = __toESM(require("../../functions/getCompressMethod")); var import_crypto = require("crypto"); var import_path2 = __toESM(require("../path")); const toArrayBuffer = (buffer) => { return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); }; class HTTPRequest extends import_Base.default { /** * Initializes a new Instance of a Web Context * @since 7.0.0 */ constructor(controller, localContext, req, res, type) { super(controller, localContext); this.rawReq = req; this.rawRes = res; this.type = type; } /** * The Type of the HTTP Body * @since 7.8.0 */ get bodyType() { if (!this.ctx.body.parsed) this.body; return this.ctx.body.type; } /** * The Request Body (JSON Automatically parsed if enabled) * @since 0.4.0 */ get body() { if (!this.ctx.body.raw.byteLength) { this.ctx.body.raw = Buffer.concat(this.ctx.body.chunks); this.ctx.body.chunks.length = 0; } if (!this.ctx.body.parsed) { const stringified = this.ctx.body.raw.toString(); switch (this.ctx.headers.get("content-type", "")) { case "application/json": { try { this.ctx.body.parsed = JSON.parse(stringified); } catch { this.ctx.body.parsed = stringified; } this.ctx.body.type = "json"; break; } case "application/x-www-form-urlencoded": { try { this.ctx.body.parsed = (0, import_parseKV.default)(stringified).toJSON(); } catch { this.ctx.body.parsed = stringified; } this.ctx.body.type = "url-encoded"; break; } case "multipart/form-data": { try { this.ctx.body.parsed = (0, import_uws2.getParts)(stringified, "multipart/form-data"); } catch { this.ctx.body.parsed = stringified; } if (!this.ctx.body.parsed) this.ctx.body.parsed = stringified; else this.ctx.body.type = "multipart"; break; } default: { this.ctx.body.parsed = stringified; break; } } } return this.ctx.body.parsed; } /** * The Raw Request Body * @since 5.5.2 */ get rawBody() { if (!this.ctx.body.raw.byteLength) { this.ctx.body.raw = Buffer.concat(this.ctx.body.chunks); this.ctx.body.chunks.length = 0; } return this.ctx.body.raw.toString(); } /** * The Raw Request Body as Buffer * @since 8.1.4 */ get rawBodyBytes() { if (!this.ctx.body.raw.byteLength) { this.ctx.body.raw = Buffer.concat(this.ctx.body.chunks); this.ctx.body.chunks.length = 0; } return this.ctx.body.raw; } /** * HTTP WWW-Authentication Checker * * This will validate the Authorization Header using the WWW-Authentication Standard, * you can choose between `basic` and `digest` authentication, in most cases `digest` * should be used unless you are using an outdated client or want to test easily. * When not matching any user the method will return `null` and the request should be * ended with a `Status.UNAUTHORIZED` (401) status code. * @example * ``` * const user = ctr.wwwAuth('basic', 'Access this Page.', { // Automatically adds www-authenticate header * bob: '123!', * rotvproHD: 'password' * }) * * if (!user) return ctr.status((s) => s.UNAUTHORIZED).print('Invalid credentials') * * ctr.print('You authenticated with user:', user) * ``` * @since 8.0.0 */ wwwAuth(type, reason, users) { if (type === "basic") this.ctx.response.headers["www-authenticate"] = `Basic realm="${encodeURI(reason)}", charset="UTF-8"`; else if (type === "digest") this.ctx.response.headers["www-authenticate"] = `Digest realm="${encodeURI(reason)}", algorithm=MD5, nonce="${Math.random()}", cnonce="${Math.random()}", opaque="${(0, import_crypto.createHash)("md5").update(encodeURI(reason)).digest("hex")}", qop="auth", charset="UTF-8"`; const spacePos = this.ctx.headers.get("authorization", "").indexOf(" "); const sentType = this.ctx.headers.get("authorization", "").slice(0, spacePos); const sentAuth = this.ctx.headers.get("authorization", "").slice(spacePos); if (!sentType || !sentAuth) return null; let user = null; switch (sentType.toLowerCase()) { case "basic": { for (const [username, password] of Object.entries(users)) { if (sentAuth.trim() === Buffer.from(`${username}:${password}`).toString("base64")) { user = username; break; } } break; } case "digest": { for (const [username, password] of Object.entries(users)) { const info = (0, import_parseKV.default)(sentAuth, "=", ",", (s) => s.replaceAll('"', "")); const ha1 = (0, import_crypto.createHash)("md5").update(`${username}:${encodeURI(reason)}:${password}`).digest("hex"); const ha2 = (0, import_crypto.createHash)("md5").update(`${this.ctx.url.method}:${info.get("uri")}`).digest("hex"); if (info.get("response") === (0, import_crypto.createHash)("md5").update(`${ha1}:${info.get("nonce")}:${info.get("nc")}:${info.get("cnonce")}:${info.get("qop")}:${ha2}`).digest("hex")) { user = username; break; } } break; } } return user; } /** * The Request Status to Send * * This will set the status of the request that the client will recieve, by default * the status will be `200`, the server will not change this value unless calling the * `.redirect()` method. If you want to add a custom message to the status you can provide * a second argument that sets that, for RFC documented codes this will automatically be * set but can be overridden, the mapping is provided by `http.STATUS_CODES` * @example * ``` * ctr.status(401).print('Unauthorized') * * // or * ctr.status(666, 'The Devil').print('The Devil') * * // or * ctr.status((c) => c.IM_A_TEAPOT).print('Im a Teapot, mate!') * ``` * @since 0.0.2 */ status(code, message) { if (typeof code === "function") this.ctx.response.status = code(import_statusEnum.default); else this.ctx.response.status = code; this.ctx.response.statusMessage = message; return this; } /** * Redirect a Client to another URL * * This will set the location header and the status to either to 301 or 302 depending * on whether the server should tell the browser that the page has permanently moved * or temporarily. Obviously this will only work correctly if the client supports the * 30x Statuses combined with the location header. * @example * ``` * ctr.redirect('https://example.com', 'permanent') // Will redirect to that URL * ``` * @since 2.8.5 */ redirect(location, type = "temporary") { if (type === "permanent") this.ctx.response.status = import_statusEnum.default.MOVED_PERMANENTLY; else this.ctx.response.status = import_statusEnum.default.FOUND; this.ctx.response.statusMessage = void 0; this.ctx.response.headers["location"] = location; return this; } /** * Print a Message to the Client (automatically Formatted) * * This Message will be the one actually sent to the client, nothing * can be "added" to the content using this function, it can only be replaced using `.print()` * To add content to the response body, use `.printPart()` instead. * @example * ``` * ctr.print({ * message: 'this is json!' * }) * * // content will be `{"message":"this is json!"}` * * /// or * * ctr.print({ * message: 'this is json!' * }, { * prettify: true * }) * * // content will be `{\n "message": "this is json!"\n}` * * /// or * * ctr.print('this is text!') * // content will be `this is text!` * ``` * @since 0.0.2 */ print(content, options = {}) { const prettify = options?.prettify ?? false; this.ctx.response.content = [content]; this.ctx.response.contentPrettify = prettify; return this; } /** * Print a Message to the client (without resetting the previous message state) * * This will add content to the current response body, if being called without `.print()` * before, the response body will be only this, basically the first call is the same as `.print()`. * this could be used when for example you want to loop over an array asynchronously without some * `await Promise.all(array.map(async() => ...))` voodo magic. Basically just call `.printPart()` * after finishing an iteration. * @example * ``` * ctr.printPart('hi') * ctr.printPart(' ') * ctr.printPart('mate') * * // content will be `hi mate` * ``` * @since 8.2.0 */ printPart(content, options = {}) { const prettify = options?.prettify ?? false; this.ctx.response.content.push(content); this.ctx.response.contentPrettify = prettify; return this; } /** * Print a Message made using the HTML Builder & Formatter * * This will set the http response body to an automatically generated html template * defined by the callback function. This also allows some quality of life features such * as `.every()` to change your html every x miliseconds without writing the frontend js * manually. * @example * ``` * const userInput = '<script>alert("xss!!!!")</script>' * * ctr.printHTML((html) => html * .t('head', {}, (t) => t * .t('title', {}, (t) => t * .escaped(userInput) // no xss attack because of .escaped() * ) * ) * .t('body', {}, (t) => t * .t( * 'h1', * { style: { color: 'red' } }, * (t) => t * .raw('Hello world matey!') * ) * ) * ) * ``` * @since 6.6.0 */ printHTML(callback, options = {}) { const htmlLanguage = options?.htmlLanguage ?? "en"; const builder = new import_HTMLBuilder.default(this.ctx.execute.route?.path.toString() ?? "default"); callback(builder); this.ctx.response.headers["content-type"] = "text/html"; this.ctx.response.content = [`<!DOCTYPE html><html ${(0, import_HTMLBuilder.parseAttributes)({ lang: htmlLanguage }, [])}>${builder["html"]}</html>`]; const path = this.ctx.url.path; if (!this.ctg.routes.htmlBuilder.some((h) => h.path.path === path)) { for (const getEvery of builder["getEveries"]) { const route = { method: "GET", path: new import_path2.default("GET", `/___rjweb-html-auto/${getEvery.id}`), async onRequest(ctr) { const res = await Promise.resolve(getEvery.getter(ctr)); getEvery.fnArguments[getEvery.fnArguments.length - 1].value = res; const builder2 = new import_HTMLBuilder.default(path, getEvery.fnArguments); getEvery.callback(builder2, res); ctr.print(builder2["html"]); }, type: "http", data: { headers: this.ctx.execute.route?.data.headers, validations: this.ctx.execute.route?.data.validations }, context: { data: {}, keep: true } }; this.ctg.routes.htmlBuilder.push(route); this.ctg.cache.routes.delete(`/___rjweb-html-auto/${getEvery.id}`); } } return this; } /** * Print the Content of a File to the Client * * This will print a file to the client using transfer encoding chunked and * if `addTypes` is enabled automatically add some content types based on the * file extension. This function wont respect any other http response body set by * `.print()` or any other normal print as this overwrites the custom ctx execution * function. * @example * ``` * ctr.printFile('./profile.png', { * addTypes: true // Automatically add Content types * }) * ``` * @since 0.6.3 */ printFile(file, options = {}) { const addTypes = options?.addTypes ?? true; const compress = options?.compress ?? true; const cache = options?.cache ?? false; this.ctx.response.headers["accept-ranges"] = "bytes"; if (addTypes && !this.ctx.response.headers["content-type"]) this.ctx.response.headers["content-type"] = (0, import_parseContentType.default)(file, this.ctg.contentTypes); this.ctx.setExecuteSelf(() => new Promise(async (resolve) => { let fileStat; try { fileStat = await import_fs.promises.stat((0, import_path.resolve)(file)); } catch (err) { this.ctx.handleError(err); return resolve(true); } let endEarly = false, start, end; if (this.ctx.headers.has("range") && /bytes=\d+(-\d+)?/.test(this.ctx.headers.get("range", ""))) { const firstExpression = this.ctx.headers.get("range", "").match(/bytes=\d+(-\d+)?/)[0].substring(6); const [startExpect, endExpect] = firstExpression.split("-"); if (!startExpect) start = 0; else start = parseInt(startExpect); if (!endExpect) end = fileStat.size; else end = parseInt(endExpect); if (end > fileStat.size) { this.ctx.response.status = import_statusEnum.default.RANGE_NOT_SATISFIABLE; this.ctx.response.statusMessage = void 0; endEarly = true; } else if (start < 0 || start > end || start > fileStat.size || start > Number.MAX_SAFE_INTEGER || end > Number.MAX_SAFE_INTEGER) { this.ctx.response.status = import_statusEnum.default.RANGE_NOT_SATISFIABLE; this.ctx.response.statusMessage = void 0; endEarly = true; } if (!endEarly) { this.ctx.response.status = import_statusEnum.default.PARTIAL_CONTENT; this.ctx.response.statusMessage = void 0; } } else start = 0, end = fileStat.size; const [compressMethod, compressHeader, compressWrite] = (0, import_getCompressMethod.default)(compress, this.ctx.headers.get("accept-encoding", ""), this.rawRes, end - start, this.ctg); this.ctx.response.headers["content-encoding"] = compressHeader; if (compressHeader) this.ctx.response.headers["vary"] = "accept-encoding"; if (start !== 0 || end !== fileStat.size) { this.ctx.response.headers["content-range"] = `bytes ${start}-${end}/${compressHeader ? "*" : fileStat.size}`; } if (this.ctg.options.performance.lastModified) try { this.ctx.response.headers["last-modified"] = fileStat.mtime.toUTCString(); } catch (err) { this.ctx.handleError(err); return resolve(true); } if (this.ctg.cache.files.has(`file::${file}`)) { this.ctx.response.content = [this.ctg.cache.files.get(`file::${file}`)]; this.ctx.response.headers["accept-range"] = void 0; return resolve(true); } else if (this.ctg.cache.files.has(`file::${this.ctg.options.httpCompression}::${file}`)) { this.ctx.response.isCompressed = true; this.ctx.response.content = [this.ctg.cache.files.get(`file::${this.ctg.options.httpCompression}::${file}`)]; this.ctx.response.headers["accept-range"] = void 0; return resolve(true); } const meta = await (0, import_writeHTTPMeta.default)(this.rawRes, this.ctx); if (!this.ctx.isAborted) this.rawRes.cork(() => { if (!endEarly && (start !== 0 || end !== fileStat.size) && this.ctx.headers.get("if-unmodified-since") !== this.ctx.response.headers["last-modified"]) { this.ctg.logger.debug("Ended unmodified-since request early because of no match"); this.ctx.response.status = import_statusEnum.default.PRECONDITION_FAILED; this.ctx.response.statusMessage = void 0; endEarly = true; } else if (!endEarly && start === 0 && end === fileStat.size && this.ctg.options.performance.lastModified && this.ctx.headers.get("if-modified-since") === this.ctx.response.headers["last-modified"]) { this.ctg.logger.debug("Ended modified-since request early because of match"); this.ctx.response.status = import_statusEnum.default.NOT_MODIFIED; this.ctx.response.statusMessage = void 0; endEarly = true; } meta(); if (endEarly) { if (!this.ctx.isAborted) this.rawRes.end(); return resolve(false); } if (compressHeader) this.ctg.logger.debug("negotiated to use", compressHeader); const stream = (0, import_fs.createReadStream)((0, import_path.resolve)(file), { start, end }); const compression = (0, import_handleCompressType.default)(compressMethod); const destroyStreams = () => { compression.destroy(); stream.destroy(); }; compression.on("data", (content) => { this.rawRes.content = toArrayBuffer(content); if (!this.ctx.isAborted) { try { this.rawRes.contentOffset = this.rawRes.getWriteOffset(); const ok = compressWrite(this.rawRes.content); if (!ok) { stream.pause(); this.rawRes.onWritable((offset) => { const sliced = this.rawRes.content.slice(offset - this.rawRes.contentOffset); const ok2 = compressWrite(sliced); if (ok2) { this.ctg.data.outgoing.increase(sliced.byteLength); this.ctg.logger.debug("sent http body chunk with bytelen", sliced.byteLength, "(delayed)"); stream.resume(); } return ok2; }); } else { this.ctg.data.outgoing.increase(content.byteLength); this.ctg.logger.debug("sent http body chunk with bytelen", content.byteLength); } } catch { } } if (cache) { const oldData = this.ctg.cache.files.get(`file::${this.ctg.options.httpCompression}::${file}`, Buffer.allocUnsafe(0)); this.ctg.cache.files.set(`file::${this.ctg.options.httpCompression}::${file}`, Buffer.concat([oldData, content])); } }).once("end", () => { if (compressHeader && !this.ctx.isAborted) this.rawRes.cork(() => this.rawRes.end()); destroyStreams(); this.ctx.events.unlist("requestAborted", destroyStreams); resolve(false); }); stream.once("error", (err) => { this.ctx.handleError(err); return resolve(true); }); stream.pipe(compression); this.ctx.events.listen("requestAborted", destroyStreams); }); else resolve(false); })); return this; } /** * Print the `data` event of a Stream to the Client * * This will print the `data` event of a stream to the client and makes the connection * stay alive until the stream is closed or the client disconnects. Best usecase of this is * probably Server Side Events for something like a front page as websockets can be quite * expensive. Remember to set the correct content type header when doing that. * @example * ``` * const fileStream = fs.createReadStream('./profile.png') * ctr.printStream(fileStream) * * // in this case though just use ctr.printFile since it does exactly this * ``` * @since 4.3.0 */ printStream(stream, options = {}) { const endRequest = options?.endRequest ?? true; const prettify = options?.prettify ?? false; const destroyAbort = options?.destroyAbort ?? true; this.headers.set("connection", "keep-alive"); this.ctx.setExecuteSelf(() => new Promise(async (resolve) => { const meta = await (0, import_writeHTTPMeta.default)(this.rawRes, this.ctx); if (!this.ctx.isAborted) this.rawRes.cork(() => { meta(); const destroyStream = () => { stream.destroy(); }; const dataListener = async (data) => { try { try { data = (await (0, import_parseContent.default)(data, prettify, this.ctg.logger)).content; } catch (err) { return this.ctx.handleError(err); } if (!this.ctx.isAborted) this.rawRes.cork(() => this.rawRes.write(data)); this.ctg.logger.debug("sent http body chunk with bytelen", data.byteLength); this.ctg.data.outgoing.increase(data.byteLength); } catch { } }, closeListener = () => { if (destroyAbort) this.ctx.events.unlist("requestAborted", destroyStream); if (endRequest) { resolve(false); if (!this.ctx.isAborted) this.rawRes.cork(() => this.rawRes.end()); } }, errorListener = (error) => { this.ctx.handleError(error); stream.removeListener("data", dataListener).removeListener("close", closeListener).removeListener("error", errorListener); return resolve(false); }; if (destroyAbort) this.ctx.events.listen("requestAborted", destroyStream); stream.on("data", dataListener).once("close", closeListener).once("error", errorListener); this.ctx.events.listen( "requestAborted", () => stream.removeListener("data", dataListener).removeListener("close", closeListener).removeListener("error", errorListener) ); }); })); return this; } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { toArrayBuffer });