123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845 |
- // This module starts the HTTP server, parses the request and calls the requested endpoint
- const jsl = require("svjsl");
- const http = require("http");
- const Readable = require("stream").Readable;
- const fs = require("fs-extra");
- const zlib = require("zlib");
- const semver = require("semver");
- const settings = require("../settings");
- const debug = require("./verboseLogging");
- const resolveIP = require("./resolveIP");
- const logger = require("./logger");
- const logRequest = require("./logRequest");
- const convertFileFormat = require("./fileFormatConverter");
- const parseURL = require("./parseURL");
- const lists = require("./lists");
- const analytics = require("./analytics");
- const jokeSubmission = require("./jokeSubmission");
- const auth = require("./auth");
- const meter = require("./meter");
- const languages = require("./languages");
- const { RateLimiterMemory, RateLimiterRes } = require("rate-limiter-flexible");
- const tr = require("./translate");
- jsl.unused(RateLimiterRes); // typedef only
- const init = () => {
- debug("HTTP", "Starting HTTP server...");
- return new Promise((resolve, reject) => {
- let endpoints = [];
- /** Whether or not the HTTP server could be initialized */
- let httpServerInitialized = false;
- /**
- * Initializes the HTTP server - should only be called once
- */
- const initHttpServer = () => {
- //#SECTION set up rate limiters
- let rl = new RateLimiterMemory({
- points: settings.httpServer.rateLimiting,
- duration: settings.httpServer.timeFrame
- });
- let rlSubm = new RateLimiterMemory({
- points: settings.jokes.submissions.rateLimiting,
- duration: settings.jokes.submissions.timeFrame
- });
- setTimeout(() => {
- if(!httpServerInitialized)
- return reject(`HTTP server initialization timed out after ${settings.httpServer.startupTimeout} seconds.\nMaybe the port ${settings.httpServer.port} is already occupied or the firewall blocks the connection.\nTry killing the process that's blocking the port or change it in settings.httpServer.port`);
- }, settings.httpServer.startupTimeout * 1000)
- //#SECTION create HTTP server
- let httpServer = http.createServer(async (req, res) => {
- let parsedURL = parseURL(req.url);
- let ip = resolveIP(req);
- let localhostIP = resolveIP.isLocal(ip);
- let headerAuth = auth.authByHeader(req, res);
- let analyticsObject = {
- ipAddress: ip,
- urlPath: parsedURL.pathArray,
- urlParameters: parsedURL.queryParams
- };
- let lang = parsedURL.queryParams ? parsedURL.queryParams.lang : "invalid-lang-code";
- if(languages.isValidLang(lang) !== true)
- lang = settings.languages.defaultLanguage;
- debug("HTTP", `Incoming ${req.method} request from "${lang}-${ip.substring(0, 8)}${localhostIP ? `..." ${jsl.colors.fg.blue}(local)${jsl.colors.rst}` : "...\""} to ${req.url}`);
-
- let fileFormat = settings.jokes.defaultFileFormat.fileFormat;
- if(!jsl.isEmpty(parsedURL.queryParams) && !jsl.isEmpty(parsedURL.queryParams.format))
- fileFormat = parseURL.getFileFormatFromQString(parsedURL.queryParams);
- if(req.url.length > settings.httpServer.maxUrlLength)
- return respondWithError(res, 108, 414, fileFormat, tr(lang, "uriTooLong", req.url.length, settings.httpServer.maxUrlLength), lang, req.url.length);
- //#SECTION check lists
- try
- {
- if(lists.isBlacklisted(ip))
- {
- logRequest("blacklisted", null, analyticsObject);
- return respondWithError(res, 103, 403, fileFormat, tr(lang, "ipBlacklisted", settings.info.author.website), lang);
- }
- debug("HTTP", `Requested URL: ${parsedURL.initialURL}`);
- if(settings.httpServer.allowCORS)
- {
- try
- {
- res.setHeader("Access-Control-Allow-Origin", "*");
- res.setHeader("Access-Control-Request-Method", "GET");
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS, PUT");
- res.setHeader("Access-Control-Allow-Headers", "*");
- }
- catch(err)
- {
- console.log(`${jsl.colors.fg.red}Error while setting up CORS headers: ${err}${jsl.colors.rst}`);
- }
- }
- res.setHeader("Allow", "GET, POST, HEAD, OPTIONS, PUT");
- if(settings.httpServer.infoHeaders)
- res.setHeader("API-Info", `${settings.info.name} v${settings.info.version} (${settings.info.docsURL})`);
- }
- catch(err)
- {
- if(jsl.isEmpty(fileFormat))
- {
- fileFormat = settings.jokes.defaultFileFormat.fileFormat;
- if(!jsl.isEmpty(parsedURL.queryParams) && !jsl.isEmpty(parsedURL.queryParams.format))
- fileFormat = parseURL.getFileFormatFromQString(parsedURL.queryParams);
- }
- analytics({
- type: "Error",
- data: {
- errorMessage: `Error while setting up the HTTP response to "${ip.substr(8)}...": ${err}`,
- ipAddress: ip,
- urlParameters: parsedURL.queryParams,
- urlPath: parsedURL.pathArray
- }
- });
- return respondWithError(res, 500, 100, fileFormat, tr(lang, "errSetupHttpResponse", err), lang);
- }
- meter.update("reqtotal", 1);
- meter.update("req1min", 1);
- meter.update("req10min", 1);
- //#SECTION GET
- if(req.method === "GET")
- {
- //#MARKER GET
- if(parsedURL.error === null)
- {
- let foundEndpoint = false;
- let urlPath = parsedURL.pathArray;
- let requestedEndpoint = "";
- let lowerCaseEndpoints = [];
- endpoints.forEach(ep => lowerCaseEndpoints.push(ep.name.toLowerCase()));
- if(!jsl.isArrayEmpty(urlPath))
- requestedEndpoint = urlPath[0];
- else
- {
- try
- {
- rl.get(ip).then(rlRes => {
- if(rlRes)
- setRateLimitedHeaders(res, rlRes);
- foundEndpoint = true;
- if((rlRes && rlRes._remainingPoints < 0) && !lists.isWhitelisted(ip) && !headerAuth.isAuthorized)
- {
- analytics.rateLimited(ip);
- logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
- return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
- }
- else
- return serveDocumentation(req, res);
- }).catch(rlRes => {
- if(typeof rlRes.message == "string")
- console.error(`Error while adding point to rate limiter: ${rlRes}`);
- else if(rlRes.remainingPoints <= 0)
- {
- logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
- return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
- }
- });
- }
- catch(err)
- {
- // setRateLimitedHeaders(res, rlRes);
- analytics.rateLimited(ip);
- logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
- return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
- }
- }
- // Disable caching now that the request is not a docs request
- if(settings.httpServer.disableCache)
- {
- res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, no-transform");
- res.setHeader("Pragma", "no-cache");
- res.setHeader("Expires", "0");
- }
- // serve favicon:
- if(!jsl.isEmpty(parsedURL.pathArray) && parsedURL.pathArray[0] == "favicon.ico")
- return pipeFile(res, settings.documentation.faviconPath, "image/x-icon", 200);
- endpoints.forEach(async (ep) => {
- if(ep.name == requestedEndpoint)
- {
- let isAuthorized = headerAuth.isAuthorized;
- let headerToken = headerAuth.token;
- // now that the request is not a docs / favicon request, the blacklist is checked and the request is made eligible for rate limiting
- if(!settings.endpoints.ratelimitBlacklist.includes(ep.name) && !isAuthorized)
- {
- try
- {
- await rl.consume(ip, 1);
- }
- catch(err)
- {
- jsl.unused(err); // gets handled elsewhere
- }
- }
-
- if(isAuthorized)
- {
- debug("HTTP", `Requester has valid token ${jsl.colors.fg.green}${req.headers[settings.auth.tokenHeaderName] || null}${jsl.colors.rst}`);
- analytics({
- type: "AuthTokenIncluded",
- data: {
- ipAddress: ip,
- urlParameters: parsedURL.queryParams,
- urlPath: parsedURL.pathArray,
- submission: headerToken
- }
- });
- }
- foundEndpoint = true;
- let callEndpoint = require(`.${ep.absPath}`);
- let meta = callEndpoint.meta;
-
- if(!jsl.isEmpty(meta) && meta.skipRateLimitCheck === true)
- {
- try
- {
- if(jsl.isEmpty(meta) || (!jsl.isEmpty(meta) && meta.noLog !== true))
- {
- if(!lists.isConsoleBlacklisted(ip))
- logRequest("success", null, analyticsObject);
- }
- return callEndpoint.call(req, res, parsedURL.pathArray, parsedURL.queryParams, fileFormat);
- }
- catch(err)
- {
- return respondWithError(res, 104, 500, fileFormat, tr(lang, "endpointInternalError", err), lang);
- }
- }
- else
- {
- try
- {
- let rlRes = await rl.get(ip);
- if(rlRes)
- setRateLimitedHeaders(res, rlRes);
- if((rlRes && rlRes._remainingPoints < 0) && !lists.isWhitelisted(ip) && !isAuthorized)
- {
- logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
- analytics.rateLimited(ip);
- return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
- }
- else
- {
- if(jsl.isEmpty(meta) || (!jsl.isEmpty(meta) && meta.noLog !== true))
- {
- if(!lists.isConsoleBlacklisted(ip))
- logRequest("success", null, analyticsObject);
- }
-
- return callEndpoint.call(req, res, parsedURL.pathArray, parsedURL.queryParams, fileFormat);
- }
- }
- catch(err)
- {
- // setRateLimitedHeaders(res, rlRes);
- logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
- analytics.rateLimited(ip);
- return respondWithError(res, 100, 500, fileFormat, tr(lang, "generalInternalError", err), lang);
- }
- }
- }
- });
- setTimeout(() => {
- if(!foundEndpoint)
- {
- if(!jsl.isEmpty(fileFormat) && req.url.toLowerCase().includes("format"))
- return respondWithError(res, 102, 404, fileFormat, tr(lang, "endpointNotFound", (!jsl.isEmpty(requestedEndpoint) ? requestedEndpoint : "/")), lang);
- else
- return respondWithErrorPage(res, 404, tr(lang, "endpointNotFound", (!jsl.isEmpty(requestedEndpoint) ? requestedEndpoint : "/")));
- }
- }, 5000);
- }
- }
- //#SECTION PUT / POST
- else if(req.method === "PUT" || req.method === "POST")
- {
- //#MARKER Joke submission
- let submissionsRateLimited = await rlSubm.get(ip);
- let dryRun = (parsedURL.queryParams && parsedURL.queryParams["dry-run"] == true) || false;
- if(!jsl.isEmpty(parsedURL.pathArray) && parsedURL.pathArray[0] == "submit" && !(submissionsRateLimited && submissionsRateLimited._remainingPoints <= 0 && !headerAuth.isAuthorized))
- {
- if(!dryRun)
- return respondWithError(res, 100, 500, fileFormat, "Joke submissions are disabled for the forseeable future.", lang);
- let data = "";
- let dataGotten = false;
- req.on("data", chunk => {
- data += chunk;
- let payloadLength = byteLength(data);
- if(payloadLength > settings.httpServer.maxPayloadSize)
- return respondWithError(res, 107, 413, fileFormat, tr(lang, "payloadTooLarge", payloadLength, settings.httpServer.maxPayloadSize), lang);
- if(!jsl.isEmpty(data))
- dataGotten = true;
- if(lists.isWhitelisted(ip))
- return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
- if(!dryRun)
- {
- rlSubm.consume(ip, 1).then(() => {
- return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
- }).catch(rlRes => {
- if(rlRes.remainingPoints <= 0)
- return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
- });
- }
- else
- {
- rl.consume(ip, 1).then(rlRes => {
- if(rlRes)
- setRateLimitedHeaders(res, rlRes);
- return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
- }).catch(rlRes => {
- if(rlRes)
- setRateLimitedHeaders(res, rlRes);
- if(rlRes.remainingPoints <= 0)
- return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
- });
- }
- });
- setTimeout(() => {
- if(!dataGotten)
- {
- debug("HTTP", "PUT request timed out");
- rlSubm.consume(ip, 1);
- return respondWithError(res, 105, 400, fileFormat, tr(lang, "requestEmptyOrTimedOut"), lang);
- }
- }, 3000);
- }
- else
- {
- //#MARKER Restart / invalid PUT / POST
- if(submissionsRateLimited && submissionsRateLimited._remainingPoints <= 0 && !headerAuth.isAuthorized)
- return respondWithError(res, 110, 429, fileFormat, tr(lang, "rateLimitedShort"), lang);
- let data = "";
- let dataGotten = false;
- req.on("data", chunk => {
- data += chunk;
- if(!jsl.isEmpty(data))
- dataGotten = true;
- if(data == process.env.RESTART_TOKEN && parsedURL.pathArray != null && parsedURL.pathArray[0] == "restart")
- {
- res.writeHead(200, {"Content-Type": parseURL.getMimeTypeFromFileFormatString(fileFormat)});
- res.end(convertFileFormat.auto(fileFormat, {
- "error": false,
- "message": `Restarting ${settings.info.name}`,
- "timestamp": new Date().getTime()
- }, lang));
- console.log(`\n\n[${logger.getTimestamp(" | ")}] ${jsl.colors.fg.red}IP ${jsl.colors.fg.yellow}${ip.substr(0, 8)}[...]${jsl.colors.fg.red} sent a restart command\n\n\n${jsl.colors.rst}`);
- process.exit(2); // if the process is exited with status 2, the package node-wrap will restart the process
- }
- else return respondWithErrorPage(res, 400, tr(lang, "invalidSubmissionOrWrongEndpoint", (parsedURL.pathArray != null ? parsedURL.pathArray[0] : "/")));
- });
- setTimeout(() => {
- if(!dataGotten)
- {
- debug("HTTP", "PUT / POST request timed out");
- return respondWithErrorPage(res, 400, tr(lang, "requestBodyIsInvalid"));
- }
- }, 3000);
- }
- }
- //#SECTION HEAD / OPTIONS
- else if(req.method === "HEAD" || req.method === "OPTIONS")
- serveDocumentation(req, res);
- //#SECTION invalid method
- else
- {
- res.writeHead(405, {"Content-Type": parseURL.getMimeTypeFromFileFormatString(fileFormat)});
- res.end(convertFileFormat.auto(fileFormat, {
- "error": true,
- "internalError": false,
- "message": `Wrong method "${req.method}" used. Expected "GET", "OPTIONS" or "HEAD"`,
- "timestamp": new Date().getTime()
- }, lang));
- }
- });
- //#MARKER other HTTP stuff
- httpServer.on("error", err => {
- logger("error", `HTTP Server Error: ${err}`, true);
- });
- httpServer.listen(settings.httpServer.port, settings.httpServer.hostname, err => {
- if(!err)
- {
- httpServerInitialized = true;
- debug("HTTP", `${jsl.colors.fg.green}HTTP Server successfully listens on port ${settings.httpServer.port}${jsl.colors.rst}`);
- return resolve();
- }
- else
- {
- debug("HTTP", `${jsl.colors.fg.red}HTTP listener init encountered error: ${settings.httpServer.port}${jsl.colors.rst}`);
- return reject(err);
- }
- });
- };
- fs.readdir(settings.endpoints.dirPath, (err1, files) => {
- if(err1)
- return reject(`Error while reading the endpoints directory: ${err1}`);
- files.forEach(file => {
- let fileName = file.split(".");
- fileName.pop();
- fileName = fileName.length > 1 ? fileName.join(".") : fileName[0];
- let endpointFilePath = `${settings.endpoints.dirPath}${file}`;
- if(fs.statSync(endpointFilePath).isFile())
- {
- endpoints.push({
- name: fileName,
- desc: require(`.${endpointFilePath}`).meta.desc, // needs an extra . cause require() is relative to this file, whereas "fs" is relative to the project root
- absPath: endpointFilePath
- });
- }
- });
- //#MARKER call HTTP server init
- initHttpServer();
- });
- });
- }
- //#MARKER error stuff
- /**
- * Sets necessary headers on a `res` object so the client knows their rate limiting numbers
- * @param {http.ServerResponse} res
- * @param {RateLimiterRes} rlRes
- */
- function setRateLimitedHeaders(res, rlRes)
- {
- try
- {
- let rlHeaders = {
- "Retry-After": rlRes.msBeforeNext ? Math.round(rlRes.msBeforeNext / 1000) : settings.httpServer.timeFrame,
- "RateLimit-Limit": settings.httpServer.rateLimiting,
- "RateLimit-Remaining": rlRes.msBeforeNext ? rlRes.remainingPoints : settings.httpServer.rateLimiting,
- "RateLimit-Reset": rlRes.msBeforeNext ? new Date(Date.now() + rlRes.msBeforeNext) : settings.httpServer.timeFrame
- }
- Object.keys(rlHeaders).forEach(key => {
- res.setHeader(key, rlHeaders[key]);
- });
- }
- catch(err)
- {
- let content = `Err: ${err}\nrlRes:\n${typeof rlRes == "object" ? JSON.stringify(rlRes, null, 4) : rlRes}\n\n\n`
- fs.appendFileSync("./msBeforeNext.log", content);
- }
- }
- /**
- * Ends the request with an error. This error gets pulled from the error registry
- * @param {http.ServerResponse} res
- * @param {Number} errorCode The error code
- * @param {Number} responseCode The HTTP response code to end the request with
- * @param {String} fileFormat The file format to respond with - automatically gets converted to MIME type
- * @param {String} errorMessage Additional error info
- * @param {String} lang Language code of the request
- * @param {...any} args Arguments to replace numbered %-placeholders with. Only use objects that are strings or convertable to them with `.toString()`!
- */
- const respondWithError = (res, errorCode, responseCode, fileFormat, errorMessage, lang, ...args) => {
- try
- {
- errorCode = errorCode.toString();
- let errFromRegistry = require("../data/errorMessages")[errorCode];
- let errObj = {};
- if(errFromRegistry == undefined)
- throw new Error(`Couldn't find errorMessages module or Node is using an outdated, cached version`);
- if(!lang || languages.isValidLang(lang) !== true)
- lang = settings.languages.defaultLanguage;
- let insArgs = (texts, insertions) => {
- if(!Array.isArray(insertions) || insertions.length <= 0)
- return texts;
- insertions.forEach((ins, i) => {
- if(Array.isArray(texts))
- texts = texts.map(tx => tx.replace(`%${i + 1}`, ins));
- else if(typeof texts == "string")
- texts = texts.replace(`%${i + 1}`, ins);
- });
- return texts;
- };
- if(fileFormat != "xml")
- {
- errObj = {
- "error": true,
- "internalError": errFromRegistry.errorInternal,
- "code": errorCode,
- "message": insArgs(errFromRegistry.errorMessage[lang], args) || insArgs(errFromRegistry.errorMessage[settings.languages.defaultLanguage], args),
- "causedBy": insArgs(errFromRegistry.causedBy[lang], args) || insArgs(errFromRegistry.causedBy[settings.languages.defaultLanguage], args),
- "timestamp": new Date().getTime()
- }
- }
- else if(fileFormat == "xml")
- {
- errObj = {
- "error": true,
- "internalError": errFromRegistry.errorInternal,
- "code": errorCode,
- "message": insArgs(errFromRegistry.errorMessage[lang], args) || insArgs(errFromRegistry.errorMessage[settings.languages.defaultLanguage], args),
- "causedBy": {"cause": insArgs(errFromRegistry.causedBy[lang], args) || insArgs(errFromRegistry.causedBy[settings.languages.defaultLanguage], args)},
- "timestamp": new Date().getTime()
- }
- }
- if(!jsl.isEmpty(errorMessage))
- errObj.additionalInfo = errorMessage;
- let converted = convertFileFormat.auto(fileFormat, errObj, lang).toString();
- return pipeString(res, converted, parseURL.getMimeTypeFromFileFormatString(fileFormat), typeof responseCode === "number" ? responseCode : 500);
- }
- catch(err)
- {
- let errMsg = `Internal error while sending error message.\nOh, the irony...\n\nPlease contact me (${settings.info.author.website}) and provide this additional info:\n${err}`;
- return pipeString(res, errMsg, "text/plain", responseCode);
- }
- };
- /**
- * Responds with an error page (which one is based on the status code).
- * Defaults to 500
- * @param {http.ServerResponse} res
- * @param {(404|500)} [statusCode=500] HTTP status code - defaults to 500
- * @param {String} [error] Additional error message that gets added to the "API-Error" response header
- */
- const respondWithErrorPage = (res, statusCode, error) => {
- statusCode = parseInt(statusCode);
- if(isNaN(statusCode))
- {
- statusCode = 500;
- error += ((!jsl.isEmpty(error) ? " - Ironically, an additional " : "An ") + "error was encountered while sending this error page: \"statusCode is not a number (in: httpServer.respondWithErrorPage)\"");
- }
- if(!jsl.isEmpty(error))
- {
- res.setHeader("Set-Cookie", `errorInfo=${JSON.stringify({"API-Error-Message": error, "API-Error-StatusCode": statusCode})}`);
- res.setHeader("API-Error", error);
- }
- return pipeFile(res, settings.documentation.errorPagePath, "text/html", statusCode);
- }
- //#MARKER response piping
- /**
- * Pipes a string into a HTTP response
- * @param {http.ServerResponse} res The HTTP res object
- * @param {String} text The response body
- * @param {String} mimeType The MIME type to respond with
- * @param {Number} [statusCode=200] The status code to respond with - defaults to 200
- */
- const pipeString = (res, text, mimeType, statusCode = 200) => {
- try
- {
- statusCode = parseInt(statusCode);
- if(isNaN(statusCode))
- throw new Error("Invalid status code");
- }
- catch(err)
- {
- res.writeHead(500, {"Content-Type": `text/plain; charset=UTF-8`});
- res.end("INTERNAL_ERR:STATUS_CODE_NOT_INT");
- return;
- }
- let s = new Readable();
- s._read = () => {};
- s.push(text);
- s.push(null);
- if(!res.writableEnded)
- {
- s.pipe(res);
- if(!res.headersSent)
- {
- res.writeHead(statusCode, {
- "Content-Type": `${mimeType}; charset=UTF-8`,
- "Content-Length": byteLength(text) // Content-Length needs the byte length, not the char length
- });
- }
- }
- }
- /**
- * Pipes a file into a HTTP response
- * @param {http.ServerResponse} res The HTTP res object
- * @param {String} filePath Path to the file to respond with - relative to the project root directory
- * @param {String} mimeType The MIME type to respond with
- * @param {Number} [statusCode=200] The status code to respond with - defaults to 200
- */
- const pipeFile = (res, filePath, mimeType, statusCode = 200) => {
- try
- {
- statusCode = parseInt(statusCode);
- if(isNaN(statusCode))
- throw new Error("err_statuscode_isnan");
- }
- catch(err)
- {
- return respondWithErrorPage(res, 500, `Encountered internal server error while piping file: wrong type for status code.`);
- }
- if(!fs.existsSync(filePath))
- return respondWithErrorPage(res, 404, `Internal error: file at "${filePath}" not found.`);
- try
- {
- if(!res.headersSent)
- {
- res.writeHead(statusCode, {
- "Content-Type": `${mimeType}; charset=UTF-8`,
- "Content-Length": fs.statSync(filePath).size
- });
- }
- let readStream = fs.createReadStream(filePath);
- readStream.pipe(res);
- }
- catch(err)
- {
- logger("fatal", err, true);
- }
- }
- //#MARKER serve docs
- /**
- * Serves the documentation page
- * @param {http.IncomingMessage} req The HTTP req object
- * @param {http.ServerResponse} res The HTTP res object
- */
- const serveDocumentation = (req, res) => {
- let resolvedURL = parseURL(req.url);
- if(!lists.isConsoleBlacklisted(resolveIP(req)))
- {
- logRequest("docs", null, {
- ipAddress: resolveIP(req),
- urlParameters: resolvedURL.queryParams,
- urlPath: resolvedURL.pathArray
- });
- }
- let selectedEncoding = getAcceptedEncoding(req);
- let fileExtension = "";
- if(selectedEncoding != null)
- fileExtension = `.${getFileExtensionFromEncoding(selectedEncoding)}`;
- debug("HTTP", `Serving docs with encoding "${selectedEncoding}"`);
- let filePath = `${settings.documentation.compiledPath}documentation.html${fileExtension}`;
- let fallbackPath = `${settings.documentation.compiledPath}documentation.html`;
- fs.exists(filePath, exists => {
- if(exists)
- {
- if(selectedEncoding == null)
- selectedEncoding = "identity"; // identity = no encoding (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding)
-
- res.setHeader("Content-Encoding", selectedEncoding);
- return pipeFile(res, filePath, "text/html", 200);
- }
- else
- return pipeFile(res, fallbackPath, "text/html", 200);
- });
- }
- //#MARKER util
- /**
- * Returns the name of the client's accepted encoding with the highest priority
- * @param {http.IncomingMessage} req The HTTP req object
- * @returns {null|"gzip"|"deflate"|"br"} Returns null if no encodings are supported, else returns the encoding name
- */
- const getAcceptedEncoding = req => {
- let selectedEncoding = null;
- let encodingPriority = [];
- settings.httpServer.encodings.brotli && encodingPriority.push("br");
- settings.httpServer.encodings.gzip && encodingPriority.push("gzip");
- settings.httpServer.encodings.deflate && encodingPriority.push("deflate");
- encodingPriority = encodingPriority.reverse();
- let acceptedEncodings = [];
- if(req.headers["accept-encoding"])
- acceptedEncodings = req.headers["accept-encoding"].split(/\s*[,]\s*/gm);
- acceptedEncodings = acceptedEncodings.reverse();
- encodingPriority.forEach(encPrio => {
- if(acceptedEncodings.includes(encPrio))
- selectedEncoding = encPrio;
- });
- return selectedEncoding;
- }
- /**
- * Returns the length of a string in bytes
- * @param {String} str
- * @returns {Number}
- */
- function byteLength(str)
- {
- if(!str)
- return 0;
- return Buffer.byteLength(str, "utf8");
- }
- /**
- * Returns the file extension for the provided encoding (without dot prefix)
- * @param {null|"gzip"|"deflate"|"br"} encoding
- * @returns {String}
- */
- const getFileExtensionFromEncoding = encoding => {
- switch(encoding)
- {
- case "gzip":
- return "gz";
- case "deflate":
- return "zz";
- case "br":
- case "brotli":
- return "br";
- default:
- return "";
- }
- }
- /**
- * Tries to serve data with an encoding supported by the client, else just serves the raw data
- * @param {http.IncomingMessage} req The HTTP req object
- * @param {http.ServerResponse} res The HTTP res object
- * @param {String} data The data to send to the client
- * @param {String} mimeType The MIME type to respond with
- */
- function tryServeEncoded(req, res, data, mimeType)
- {
- let selectedEncoding = getAcceptedEncoding(req);
- debug("HTTP", `Trying to serve with encoding ${selectedEncoding}`);
- if(selectedEncoding)
- res.setHeader("Content-Encoding", selectedEncoding);
- else
- res.setHeader("Content-Encoding", "identity");
- switch(selectedEncoding)
- {
- case "br":
- if(!semver.lt(process.version, "v11.7.0")) // Brotli was added in Node v11.7.0
- {
- zlib.brotliCompress(data, (err, encRes) => {
- if(!err)
- return pipeString(res, encRes, mimeType);
- else
- return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
- });
- }
- else
- {
- res.setHeader("Content-Encoding", "identity");
- return pipeString(res, data, mimeType);
- }
- break;
- case "gzip":
- zlib.gzip(data, (err, encRes) => {
- if(!err)
- return pipeString(res, encRes, mimeType);
- else
- return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
- });
- break;
- case "deflate":
- zlib.deflate(data, (err, encRes) => {
- if(!err)
- return pipeString(res, encRes, mimeType);
- else
- return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
- });
- break;
- default:
- res.setHeader("Content-Encoding", "identity");
- return pipeString(res, data, mimeType);
- }
- }
- module.exports = { init, respondWithError, respondWithErrorPage, pipeString, pipeFile, serveDocumentation, getAcceptedEncoding, getFileExtensionFromEncoding, tryServeEncoded };
|