rjweb-server
Version:
Easy and Robust Way to create a Web Server with Many Easy-to-use Features in NodeJS
502 lines (501 loc) • 21.7 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const global_1 = require("../../types/global");
const Base_1 = __importDefault(require("./Base"));
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const writeHeaders_1 = __importDefault(require("../../functions/writeHeaders"));
const http_1 = require("http");
const parseContentType_1 = __importDefault(require("../../functions/parseContentType"));
const zod_1 = require("zod");
const parseKV_1 = __importDefault(require("../../functions/parseKV"));
const getCompressMethod_1 = __importDefault(require("../../functions/getCompressMethod"));
const status_1 = __importDefault(require("../../enums/status"));
const stream_1 = require("stream");
const parseContent_1 = __importDefault(require("../../functions/parseContent"));
const crypto_1 = require("crypto");
class HttpRequestContext extends Base_1.default {
constructor(context, rawContext, abort) {
super(context);
this.rawContext = rawContext;
this.abort = abort;
/**
* HTTP Status Codes Enum
* @since 9.0.0
*/ this.$status = status_1.default;
this.type = context.type;
}
/**
* HTTP Abort Controller (please use to decrease server load)
* @since 9.0.0
*/ $abort(callback) {
if (callback)
this.abort.addEventListener('abort', callback);
return this.abort.aborted;
}
/**
* The Request Body (JSON and Urlencoding Automatically parsed if enabled)
* @since 0.4.0
*/ async body() {
const body = await this.context.awaitBody(this);
if (!this.context.body.parsed) {
const stringified = body.toString();
switch (this.context.headers.get('content-type', '')) {
case "application/json": {
try {
this.context.body.parsed = JSON.parse(stringified);
this.context.body.type = 'json';
}
catch {
this.context.body.parsed = stringified;
}
break;
}
case "application/x-www-form-urlencoded": {
try {
this.context.body.parsed = (0, parseKV_1.default)('Object', stringified);
this.context.body.type = 'url-encoded';
}
catch {
this.context.body.parsed = stringified;
}
break;
}
default: {
this.context.body.parsed = stringified;
break;
}
}
}
return this.context.body.parsed;
}
/**
* The HTTP Body Type
* @since 9.0.0
*/ async bodyType() {
await this.body();
return this.context.body.type;
}
/**
* The Raw Request Body
* @since 5.5.2
*/ async rawBody(encoding) {
const body = await this.context.awaitBody(this);
return body.toString(encoding);
}
/**
* The Raw Request Body as Buffer
* @since 8.1.4
*/ async rawBodyBytes() {
const body = await this.context.awaitBody(this);
return body;
}
/**
* Bind the Body using Zod
*
* This uses `.body` internally so no binary data
* @example
* ```
* const [ data, error ] = await ctr.bindBody((z) => z.object({
* name: z.string().max(24),
* gender: z.union([ z.literal('male'), z.literal('female') ])
* }))
*
* if (!data) return ctr.status((s) => s.BAD_REQUEST).print(error.toString())
*
* ctr.print('Everything valid! 👍')
* ctr.printPart(`
* your name is ${data.name}
* and you are a ${data.gender}
* `)
* ```
* @since 8.8.0
*/ async bindBody(schema) {
const fullSchema = typeof schema === 'function' ? schema(zod_1.z) : schema, parsed = await fullSchema.safeParseAsync(await this.body());
if (!parsed.success)
return [null, parsed.error];
return [parsed.data, null];
}
/**
* 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(ctr.$status.UNAUTHORIZED).print('Invalid credentials')
*
* ctr.print(`You authenticated with user: ${user}`)
* ```
* @since 8.0.0
*/ wwwAuth(type, reason, users) {
if (type === 'basic')
this.headers.set('www-authenticate', `Basic realm="${encodeURI(reason)}", charset="UTF-8"`);
else if (type === 'digest')
this.headers.set('www-authenticate', `Digest realm="${encodeURI(reason)}", algorithm=MD5, nonce="${Math.random()}", cnonce="${Math.random()}", opaque="${(0, crypto_1.createHash)('md5').update(encodeURI(reason)).digest('hex')}", qop="auth", charset="UTF-8"`);
const spacePos = this.headers.get('authorization', '').indexOf(' ');
if (spacePos === -1)
return null;
const sentType = this.headers.get('authorization', '').slice(0, spacePos), sentAuth = this.headers.get('authorization', '').slice(spacePos).trim();
if (!sentType || !sentAuth)
return null;
let user = null;
switch (sentType.toLowerCase()) {
case "basic": {
for (const [username, password] of Object.entries(users)) {
if (sentAuth === Buffer.from(`${username}:${password}`).toString('base64')) {
user = username;
break;
}
}
break;
}
case "digest": {
const info = (0, parseKV_1.default)('ValueCollection', sentAuth, '=', ',', (s) => s.replaceAll('"', '')), ha2 = (0, crypto_1.createHash)('md5').update(`${this.url.method}:${info.get('uri')}`).digest('hex');
for (const [username, password] of Object.entries(users)) {
const ha1 = (0, crypto_1.createHash)('md5').update(`${username}:${encodeURI(reason)}:${password}`).digest('hex');
if (info.get('response') === (0, crypto_1.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;
}
/**
* Clear the active Ratelimit of the Client
*
* This Clears the currently active Ratelimit (on this endpoint) of the Client, remember:
* you cant call this in a normal endpoint if the max hits are already reached since well...
* they are already reached.
* @since 8.6.0
*/ clearRateLimit() {
if (!this.context.route || !this.context.route.ratelimit || this.context.route.ratelimit.maxHits === Infinity)
return this;
this.global.rateLimits.delete(`http+${this.client.ip}-${this.context.route.ratelimit.sortTo}`);
return this;
}
/**
* Skips counting the request to the Client IPs Rate limit (if there is one)
*
* When a specific IP makes a request to an endpoint under a ratelimit, the maxhits will be
* increased instantly to prevent bypassing the rate limit by spamming requests faster than the host can
* handle. When this function is called, the server removes the set hit again.
* @since 8.6.0
*/ skipRateLimit() {
if (!this.context.route || !this.context.route.ratelimit || this.context.route.ratelimit.maxHits === Infinity)
return this;
const data = this.global.rateLimits.get(`http+${this.client.ip}-${this.context.route.ratelimit.sortTo}`, {
hits: 1,
end: Date.now() + this.context.route.ratelimit.timeWindow
});
if (data.hits === 0)
return this;
this.global.rateLimits.set(`http+${this.client.ip}-${this.context.route.ratelimit.sortTo}`, {
...data,
hits: data.hits - 1
});
return this;
}
/**
* Get Infos about the current Ratelimit
*
* This will get all information about the currently applied ratelimit
* to the endpoint. If none is active, will return `null`.
* @since 8.6.0
*/ getRateLimit() {
if (!this.context.route || !this.context.route.ratelimit || this.context.route.ratelimit.maxHits === Infinity)
return null;
const data = this.global.rateLimits.get(`http+${this.client.ip}-${this.context.route.ratelimit.sortTo}`, {
hits: 0,
end: Date.now() + this.context.route.ratelimit.timeWindow
});
return {
hits: data.hits,
maxHits: this.context.route.ratelimit.maxHits,
hasPenalty: data.hits > this.context.route.ratelimit.maxHits,
penalty: this.context.route.ratelimit.penalty,
timeWindow: this.context.route.ratelimit.timeWindow,
get endsAt() { return new Date(data.end); },
endsIn: data.end - Date.now()
};
}
/**
* 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(ctr.$status.IM_A_TEAPOT).print('Im a Teapot, mate!')
* ```
* @since 0.0.2
*/ status(code, message) {
this.context.response.status = code;
this.context.response.statusText = message || null;
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.context.response.status = global_1.Status.MOVED_PERMANENTLY;
else
this.context.response.status = global_1.Status.FOUND;
this.context.response.statusText = null;
this.context.response.headers.set('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!'
* }, 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, prettify = false) {
this.context.response.content = content;
this.context.response.prettify = prettify;
return this;
}
/**
* Print a Message to the client (without resetting the previous message state)
*
* This will turn your response into a chunked response, this means that you cannot
* add headers or cookies after this function has been called. This function is useful
* if you want to add content to the response body without resetting the previous content.
* And when you manually want to print massive amounts of data to the client without having
* to store it in memory.
* @example
* ```
* const file = fs.createReadStream('./10GB.bin')
*
* ctr.printChunked((print) => new Promise<void>((end) => {
* file.on('data', (chunk) => {
* file.pause()
* print(chunk)
* .then(() => file.resume())
* })
*
* file.on('end', () => {
* end()
* })
* }))
* ```
* @since 8.2.0
*/ printChunked(callback) {
if (this.context.chunked)
throw new Error('Cannot call printChunked multiple times');
this.context.chunked = true;
let canStartReading = () => { }, canStartReadingBool = false;
const stream = new stream_1.Duplex({
read() {
canStartReading();
canStartReadingBool = true;
}, write(chunk, _, callback) {
this.push(chunk);
callback();
}, final(callback) {
this.push(null);
callback();
}
});
stream.pause();
this.context.setExecuteSelf(() => new Promise(async (resolve) => {
await (0, writeHeaders_1.default)(null, this.context, this.rawContext);
this.rawContext
.compress((0, getCompressMethod_1.default)(true, this.headers.get('accept-encoding', ''), 0, this.context.ip.isProxied, this.context.global))
.status(this.context.response.status, this.context.response.statusText || http_1.STATUS_CODES[this.context.response.status] || 'Unknown')
.write(stream);
resolve(false);
if (!callback)
return;
if (!canStartReadingBool)
await new Promise((resolve) => {
canStartReading = resolve;
});
stream.resume();
try {
await callback(async (content) => {
const { content: parsed } = await (0, parseContent_1.default)(content, this.context.response.prettify, this.context.global.logger);
await new Promise((resolve) => stream.write(new Uint8Array(parsed), () => resolve()));
}).then(() => stream.end());
}
catch (err) {
console.error(err);
}
}));
if (!callback)
return stream;
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;
this.context.response.headers.set('accept-ranges', 'bytes');
if (addTypes && this.context.response.headers.get('content-type', 'text/plain') === 'text/plain')
this.context.response.headers.set('content-type', (0, parseContentType_1.default)(file, this.global.contentTypes));
this.context.setExecuteSelf(() => new Promise(async (resolve) => {
file = path.resolve(file);
let fileStat;
try {
fileStat = await fs.stat(file);
if (!fileStat.isFile() && !fileStat.isFIFO())
throw new Error('Not a File');
this.headers.set('content-length', fileStat.size.toString());
if (!this.headers.has('content-disposition')) {
this.headers.set('content-disposition', `${options.download ?? this.headers.get('content-type') === 'application/octet-stream' ? 'attachment' : 'inline'}; filename="${options.name ?? path.basename(file)}"`);
}
}
catch (err) {
this.context.handleError(err, 'printFile.fs.stat');
return resolve(true);
}
let endEarly = false, start, end;
if (this.context.headers.has('range')) {
const match = this.context.headers.get('range', '').match(/bytes=\d+(-\d+)?/);
if (match) {
const firstExpression = match[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.context.response.status = global_1.Status.RANGE_NOT_SATISFIABLE;
this.context.response.statusText = null;
endEarly = true;
}
else if (start < 0 || start > end || start > fileStat.size || start > Number.MAX_SAFE_INTEGER || end > Number.MAX_SAFE_INTEGER) {
this.context.response.status = global_1.Status.RANGE_NOT_SATISFIABLE;
this.context.response.statusText = null;
endEarly = true;
}
if (!endEarly) {
this.context.response.status = global_1.Status.PARTIAL_CONTENT;
this.context.response.statusText = null;
}
}
else
start = 0, end = fileStat.size;
}
else
start = 0, end = fileStat.size;
if (start !== 0 || end !== fileStat.size) {
this.headers.set('content-range', `bytes ${start}-${end}/${fileStat.size}`);
}
if (this.global.options.performance.lastModified) {
const lastModified = fileStat.mtime.toUTCString();
this.context.response.headers.set('last-modified', lastModified);
if (this.context.headers.get('if-modified-since') === lastModified) {
await this.rawContext.status(global_1.Status.NOT_MODIFIED, http_1.STATUS_CODES[this.context.response.status] || 'Unknown').write(new ArrayBuffer(0));
return resolve(false);
}
}
const continueWrites = await (0, writeHeaders_1.default)(null, this.context, this.rawContext);
if (!continueWrites)
return resolve(false);
this.rawContext
.compress((0, getCompressMethod_1.default)(compress, this.headers.get('accept-encoding', ''), fileStat.size, this.context.ip.isProxied, this.global))
.status(this.context.response.status, this.context.response.statusText || http_1.STATUS_CODES[this.context.response.status] || 'Unknown')
.writeFile(file, start, end);
return resolve(false);
}));
return this;
}
}
exports.default = HttpRequestContext;