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
JavaScript
"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
});