httpServer.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845
  1. // This module starts the HTTP server, parses the request and calls the requested endpoint
  2. const jsl = require("svjsl");
  3. const http = require("http");
  4. const Readable = require("stream").Readable;
  5. const fs = require("fs-extra");
  6. const zlib = require("zlib");
  7. const semver = require("semver");
  8. const settings = require("../settings");
  9. const debug = require("./verboseLogging");
  10. const resolveIP = require("./resolveIP");
  11. const logger = require("./logger");
  12. const logRequest = require("./logRequest");
  13. const convertFileFormat = require("./fileFormatConverter");
  14. const parseURL = require("./parseURL");
  15. const lists = require("./lists");
  16. const analytics = require("./analytics");
  17. const jokeSubmission = require("./jokeSubmission");
  18. const auth = require("./auth");
  19. const meter = require("./meter");
  20. const languages = require("./languages");
  21. const { RateLimiterMemory, RateLimiterRes } = require("rate-limiter-flexible");
  22. const tr = require("./translate");
  23. jsl.unused(RateLimiterRes); // typedef only
  24. const init = () => {
  25. debug("HTTP", "Starting HTTP server...");
  26. return new Promise((resolve, reject) => {
  27. let endpoints = [];
  28. /** Whether or not the HTTP server could be initialized */
  29. let httpServerInitialized = false;
  30. /**
  31. * Initializes the HTTP server - should only be called once
  32. */
  33. const initHttpServer = () => {
  34. //#SECTION set up rate limiters
  35. let rl = new RateLimiterMemory({
  36. points: settings.httpServer.rateLimiting,
  37. duration: settings.httpServer.timeFrame
  38. });
  39. let rlSubm = new RateLimiterMemory({
  40. points: settings.jokes.submissions.rateLimiting,
  41. duration: settings.jokes.submissions.timeFrame
  42. });
  43. setTimeout(() => {
  44. if(!httpServerInitialized)
  45. 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`);
  46. }, settings.httpServer.startupTimeout * 1000)
  47. //#SECTION create HTTP server
  48. let httpServer = http.createServer(async (req, res) => {
  49. let parsedURL = parseURL(req.url);
  50. let ip = resolveIP(req);
  51. let localhostIP = resolveIP.isLocal(ip);
  52. let headerAuth = auth.authByHeader(req, res);
  53. let analyticsObject = {
  54. ipAddress: ip,
  55. urlPath: parsedURL.pathArray,
  56. urlParameters: parsedURL.queryParams
  57. };
  58. let lang = parsedURL.queryParams ? parsedURL.queryParams.lang : "invalid-lang-code";
  59. if(languages.isValidLang(lang) !== true)
  60. lang = settings.languages.defaultLanguage;
  61. debug("HTTP", `Incoming ${req.method} request from "${lang}-${ip.substring(0, 8)}${localhostIP ? `..." ${jsl.colors.fg.blue}(local)${jsl.colors.rst}` : "...\""} to ${req.url}`);
  62. let fileFormat = settings.jokes.defaultFileFormat.fileFormat;
  63. if(!jsl.isEmpty(parsedURL.queryParams) && !jsl.isEmpty(parsedURL.queryParams.format))
  64. fileFormat = parseURL.getFileFormatFromQString(parsedURL.queryParams);
  65. if(req.url.length > settings.httpServer.maxUrlLength)
  66. return respondWithError(res, 108, 414, fileFormat, tr(lang, "uriTooLong", req.url.length, settings.httpServer.maxUrlLength), lang, req.url.length);
  67. //#SECTION check lists
  68. try
  69. {
  70. if(lists.isBlacklisted(ip))
  71. {
  72. logRequest("blacklisted", null, analyticsObject);
  73. return respondWithError(res, 103, 403, fileFormat, tr(lang, "ipBlacklisted", settings.info.author.website), lang);
  74. }
  75. debug("HTTP", `Requested URL: ${parsedURL.initialURL}`);
  76. if(settings.httpServer.allowCORS)
  77. {
  78. try
  79. {
  80. res.setHeader("Access-Control-Allow-Origin", "*");
  81. res.setHeader("Access-Control-Request-Method", "GET");
  82. res.setHeader("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS, PUT");
  83. res.setHeader("Access-Control-Allow-Headers", "*");
  84. }
  85. catch(err)
  86. {
  87. console.log(`${jsl.colors.fg.red}Error while setting up CORS headers: ${err}${jsl.colors.rst}`);
  88. }
  89. }
  90. res.setHeader("Allow", "GET, POST, HEAD, OPTIONS, PUT");
  91. if(settings.httpServer.infoHeaders)
  92. res.setHeader("API-Info", `${settings.info.name} v${settings.info.version} (${settings.info.docsURL})`);
  93. }
  94. catch(err)
  95. {
  96. if(jsl.isEmpty(fileFormat))
  97. {
  98. fileFormat = settings.jokes.defaultFileFormat.fileFormat;
  99. if(!jsl.isEmpty(parsedURL.queryParams) && !jsl.isEmpty(parsedURL.queryParams.format))
  100. fileFormat = parseURL.getFileFormatFromQString(parsedURL.queryParams);
  101. }
  102. analytics({
  103. type: "Error",
  104. data: {
  105. errorMessage: `Error while setting up the HTTP response to "${ip.substr(8)}...": ${err}`,
  106. ipAddress: ip,
  107. urlParameters: parsedURL.queryParams,
  108. urlPath: parsedURL.pathArray
  109. }
  110. });
  111. return respondWithError(res, 500, 100, fileFormat, tr(lang, "errSetupHttpResponse", err), lang);
  112. }
  113. meter.update("reqtotal", 1);
  114. meter.update("req1min", 1);
  115. meter.update("req10min", 1);
  116. //#SECTION GET
  117. if(req.method === "GET")
  118. {
  119. //#MARKER GET
  120. if(parsedURL.error === null)
  121. {
  122. let foundEndpoint = false;
  123. let urlPath = parsedURL.pathArray;
  124. let requestedEndpoint = "";
  125. let lowerCaseEndpoints = [];
  126. endpoints.forEach(ep => lowerCaseEndpoints.push(ep.name.toLowerCase()));
  127. if(!jsl.isArrayEmpty(urlPath))
  128. requestedEndpoint = urlPath[0];
  129. else
  130. {
  131. try
  132. {
  133. rl.get(ip).then(rlRes => {
  134. if(rlRes)
  135. setRateLimitedHeaders(res, rlRes);
  136. foundEndpoint = true;
  137. if((rlRes && rlRes._remainingPoints < 0) && !lists.isWhitelisted(ip) && !headerAuth.isAuthorized)
  138. {
  139. analytics.rateLimited(ip);
  140. logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
  141. return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
  142. }
  143. else
  144. return serveDocumentation(req, res);
  145. }).catch(rlRes => {
  146. if(typeof rlRes.message == "string")
  147. console.error(`Error while adding point to rate limiter: ${rlRes}`);
  148. else if(rlRes.remainingPoints <= 0)
  149. {
  150. logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
  151. return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
  152. }
  153. });
  154. }
  155. catch(err)
  156. {
  157. // setRateLimitedHeaders(res, rlRes);
  158. analytics.rateLimited(ip);
  159. logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
  160. return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
  161. }
  162. }
  163. // Disable caching now that the request is not a docs request
  164. if(settings.httpServer.disableCache)
  165. {
  166. res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, no-transform");
  167. res.setHeader("Pragma", "no-cache");
  168. res.setHeader("Expires", "0");
  169. }
  170. // serve favicon:
  171. if(!jsl.isEmpty(parsedURL.pathArray) && parsedURL.pathArray[0] == "favicon.ico")
  172. return pipeFile(res, settings.documentation.faviconPath, "image/x-icon", 200);
  173. endpoints.forEach(async (ep) => {
  174. if(ep.name == requestedEndpoint)
  175. {
  176. let isAuthorized = headerAuth.isAuthorized;
  177. let headerToken = headerAuth.token;
  178. // now that the request is not a docs / favicon request, the blacklist is checked and the request is made eligible for rate limiting
  179. if(!settings.endpoints.ratelimitBlacklist.includes(ep.name) && !isAuthorized)
  180. {
  181. try
  182. {
  183. await rl.consume(ip, 1);
  184. }
  185. catch(err)
  186. {
  187. jsl.unused(err); // gets handled elsewhere
  188. }
  189. }
  190. if(isAuthorized)
  191. {
  192. debug("HTTP", `Requester has valid token ${jsl.colors.fg.green}${req.headers[settings.auth.tokenHeaderName] || null}${jsl.colors.rst}`);
  193. analytics({
  194. type: "AuthTokenIncluded",
  195. data: {
  196. ipAddress: ip,
  197. urlParameters: parsedURL.queryParams,
  198. urlPath: parsedURL.pathArray,
  199. submission: headerToken
  200. }
  201. });
  202. }
  203. foundEndpoint = true;
  204. let callEndpoint = require(`.${ep.absPath}`);
  205. let meta = callEndpoint.meta;
  206. if(!jsl.isEmpty(meta) && meta.skipRateLimitCheck === true)
  207. {
  208. try
  209. {
  210. if(jsl.isEmpty(meta) || (!jsl.isEmpty(meta) && meta.noLog !== true))
  211. {
  212. if(!lists.isConsoleBlacklisted(ip))
  213. logRequest("success", null, analyticsObject);
  214. }
  215. return callEndpoint.call(req, res, parsedURL.pathArray, parsedURL.queryParams, fileFormat);
  216. }
  217. catch(err)
  218. {
  219. return respondWithError(res, 104, 500, fileFormat, tr(lang, "endpointInternalError", err), lang);
  220. }
  221. }
  222. else
  223. {
  224. try
  225. {
  226. let rlRes = await rl.get(ip);
  227. if(rlRes)
  228. setRateLimitedHeaders(res, rlRes);
  229. if((rlRes && rlRes._remainingPoints < 0) && !lists.isWhitelisted(ip) && !isAuthorized)
  230. {
  231. logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
  232. analytics.rateLimited(ip);
  233. return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
  234. }
  235. else
  236. {
  237. if(jsl.isEmpty(meta) || (!jsl.isEmpty(meta) && meta.noLog !== true))
  238. {
  239. if(!lists.isConsoleBlacklisted(ip))
  240. logRequest("success", null, analyticsObject);
  241. }
  242. return callEndpoint.call(req, res, parsedURL.pathArray, parsedURL.queryParams, fileFormat);
  243. }
  244. }
  245. catch(err)
  246. {
  247. // setRateLimitedHeaders(res, rlRes);
  248. logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
  249. analytics.rateLimited(ip);
  250. return respondWithError(res, 100, 500, fileFormat, tr(lang, "generalInternalError", err), lang);
  251. }
  252. }
  253. }
  254. });
  255. setTimeout(() => {
  256. if(!foundEndpoint)
  257. {
  258. if(!jsl.isEmpty(fileFormat) && req.url.toLowerCase().includes("format"))
  259. return respondWithError(res, 102, 404, fileFormat, tr(lang, "endpointNotFound", (!jsl.isEmpty(requestedEndpoint) ? requestedEndpoint : "/")), lang);
  260. else
  261. return respondWithErrorPage(res, 404, tr(lang, "endpointNotFound", (!jsl.isEmpty(requestedEndpoint) ? requestedEndpoint : "/")));
  262. }
  263. }, 5000);
  264. }
  265. }
  266. //#SECTION PUT / POST
  267. else if(req.method === "PUT" || req.method === "POST")
  268. {
  269. //#MARKER Joke submission
  270. let submissionsRateLimited = await rlSubm.get(ip);
  271. let dryRun = (parsedURL.queryParams && parsedURL.queryParams["dry-run"] == true) || false;
  272. if(!jsl.isEmpty(parsedURL.pathArray) && parsedURL.pathArray[0] == "submit" && !(submissionsRateLimited && submissionsRateLimited._remainingPoints <= 0 && !headerAuth.isAuthorized))
  273. {
  274. if(!dryRun)
  275. return respondWithError(res, 100, 500, fileFormat, "Joke submissions are disabled for the forseeable future.", lang);
  276. let data = "";
  277. let dataGotten = false;
  278. req.on("data", chunk => {
  279. data += chunk;
  280. let payloadLength = byteLength(data);
  281. if(payloadLength > settings.httpServer.maxPayloadSize)
  282. return respondWithError(res, 107, 413, fileFormat, tr(lang, "payloadTooLarge", payloadLength, settings.httpServer.maxPayloadSize), lang);
  283. if(!jsl.isEmpty(data))
  284. dataGotten = true;
  285. if(lists.isWhitelisted(ip))
  286. return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
  287. if(!dryRun)
  288. {
  289. rlSubm.consume(ip, 1).then(() => {
  290. return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
  291. }).catch(rlRes => {
  292. if(rlRes.remainingPoints <= 0)
  293. return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
  294. });
  295. }
  296. else
  297. {
  298. rl.consume(ip, 1).then(rlRes => {
  299. if(rlRes)
  300. setRateLimitedHeaders(res, rlRes);
  301. return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
  302. }).catch(rlRes => {
  303. if(rlRes)
  304. setRateLimitedHeaders(res, rlRes);
  305. if(rlRes.remainingPoints <= 0)
  306. return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
  307. });
  308. }
  309. });
  310. setTimeout(() => {
  311. if(!dataGotten)
  312. {
  313. debug("HTTP", "PUT request timed out");
  314. rlSubm.consume(ip, 1);
  315. return respondWithError(res, 105, 400, fileFormat, tr(lang, "requestEmptyOrTimedOut"), lang);
  316. }
  317. }, 3000);
  318. }
  319. else
  320. {
  321. //#MARKER Restart / invalid PUT / POST
  322. if(submissionsRateLimited && submissionsRateLimited._remainingPoints <= 0 && !headerAuth.isAuthorized)
  323. return respondWithError(res, 110, 429, fileFormat, tr(lang, "rateLimitedShort"), lang);
  324. let data = "";
  325. let dataGotten = false;
  326. req.on("data", chunk => {
  327. data += chunk;
  328. if(!jsl.isEmpty(data))
  329. dataGotten = true;
  330. if(data == process.env.RESTART_TOKEN && parsedURL.pathArray != null && parsedURL.pathArray[0] == "restart")
  331. {
  332. res.writeHead(200, {"Content-Type": parseURL.getMimeTypeFromFileFormatString(fileFormat)});
  333. res.end(convertFileFormat.auto(fileFormat, {
  334. "error": false,
  335. "message": `Restarting ${settings.info.name}`,
  336. "timestamp": new Date().getTime()
  337. }, lang));
  338. 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}`);
  339. process.exit(2); // if the process is exited with status 2, the package node-wrap will restart the process
  340. }
  341. else return respondWithErrorPage(res, 400, tr(lang, "invalidSubmissionOrWrongEndpoint", (parsedURL.pathArray != null ? parsedURL.pathArray[0] : "/")));
  342. });
  343. setTimeout(() => {
  344. if(!dataGotten)
  345. {
  346. debug("HTTP", "PUT / POST request timed out");
  347. return respondWithErrorPage(res, 400, tr(lang, "requestBodyIsInvalid"));
  348. }
  349. }, 3000);
  350. }
  351. }
  352. //#SECTION HEAD / OPTIONS
  353. else if(req.method === "HEAD" || req.method === "OPTIONS")
  354. serveDocumentation(req, res);
  355. //#SECTION invalid method
  356. else
  357. {
  358. res.writeHead(405, {"Content-Type": parseURL.getMimeTypeFromFileFormatString(fileFormat)});
  359. res.end(convertFileFormat.auto(fileFormat, {
  360. "error": true,
  361. "internalError": false,
  362. "message": `Wrong method "${req.method}" used. Expected "GET", "OPTIONS" or "HEAD"`,
  363. "timestamp": new Date().getTime()
  364. }, lang));
  365. }
  366. });
  367. //#MARKER other HTTP stuff
  368. httpServer.on("error", err => {
  369. logger("error", `HTTP Server Error: ${err}`, true);
  370. });
  371. httpServer.listen(settings.httpServer.port, settings.httpServer.hostname, err => {
  372. if(!err)
  373. {
  374. httpServerInitialized = true;
  375. debug("HTTP", `${jsl.colors.fg.green}HTTP Server successfully listens on port ${settings.httpServer.port}${jsl.colors.rst}`);
  376. return resolve();
  377. }
  378. else
  379. {
  380. debug("HTTP", `${jsl.colors.fg.red}HTTP listener init encountered error: ${settings.httpServer.port}${jsl.colors.rst}`);
  381. return reject(err);
  382. }
  383. });
  384. };
  385. fs.readdir(settings.endpoints.dirPath, (err1, files) => {
  386. if(err1)
  387. return reject(`Error while reading the endpoints directory: ${err1}`);
  388. files.forEach(file => {
  389. let fileName = file.split(".");
  390. fileName.pop();
  391. fileName = fileName.length > 1 ? fileName.join(".") : fileName[0];
  392. let endpointFilePath = `${settings.endpoints.dirPath}${file}`;
  393. if(fs.statSync(endpointFilePath).isFile())
  394. {
  395. endpoints.push({
  396. name: fileName,
  397. desc: require(`.${endpointFilePath}`).meta.desc, // needs an extra . cause require() is relative to this file, whereas "fs" is relative to the project root
  398. absPath: endpointFilePath
  399. });
  400. }
  401. });
  402. //#MARKER call HTTP server init
  403. initHttpServer();
  404. });
  405. });
  406. }
  407. //#MARKER error stuff
  408. /**
  409. * Sets necessary headers on a `res` object so the client knows their rate limiting numbers
  410. * @param {http.ServerResponse} res
  411. * @param {RateLimiterRes} rlRes
  412. */
  413. function setRateLimitedHeaders(res, rlRes)
  414. {
  415. try
  416. {
  417. let rlHeaders = {
  418. "Retry-After": rlRes.msBeforeNext ? Math.round(rlRes.msBeforeNext / 1000) : settings.httpServer.timeFrame,
  419. "RateLimit-Limit": settings.httpServer.rateLimiting,
  420. "RateLimit-Remaining": rlRes.msBeforeNext ? rlRes.remainingPoints : settings.httpServer.rateLimiting,
  421. "RateLimit-Reset": rlRes.msBeforeNext ? new Date(Date.now() + rlRes.msBeforeNext) : settings.httpServer.timeFrame
  422. }
  423. Object.keys(rlHeaders).forEach(key => {
  424. res.setHeader(key, rlHeaders[key]);
  425. });
  426. }
  427. catch(err)
  428. {
  429. let content = `Err: ${err}\nrlRes:\n${typeof rlRes == "object" ? JSON.stringify(rlRes, null, 4) : rlRes}\n\n\n`
  430. fs.appendFileSync("./msBeforeNext.log", content);
  431. }
  432. }
  433. /**
  434. * Ends the request with an error. This error gets pulled from the error registry
  435. * @param {http.ServerResponse} res
  436. * @param {Number} errorCode The error code
  437. * @param {Number} responseCode The HTTP response code to end the request with
  438. * @param {String} fileFormat The file format to respond with - automatically gets converted to MIME type
  439. * @param {String} errorMessage Additional error info
  440. * @param {String} lang Language code of the request
  441. * @param {...any} args Arguments to replace numbered %-placeholders with. Only use objects that are strings or convertable to them with `.toString()`!
  442. */
  443. const respondWithError = (res, errorCode, responseCode, fileFormat, errorMessage, lang, ...args) => {
  444. try
  445. {
  446. errorCode = errorCode.toString();
  447. let errFromRegistry = require("../data/errorMessages")[errorCode];
  448. let errObj = {};
  449. if(errFromRegistry == undefined)
  450. throw new Error(`Couldn't find errorMessages module or Node is using an outdated, cached version`);
  451. if(!lang || languages.isValidLang(lang) !== true)
  452. lang = settings.languages.defaultLanguage;
  453. let insArgs = (texts, insertions) => {
  454. if(!Array.isArray(insertions) || insertions.length <= 0)
  455. return texts;
  456. insertions.forEach((ins, i) => {
  457. if(Array.isArray(texts))
  458. texts = texts.map(tx => tx.replace(`%${i + 1}`, ins));
  459. else if(typeof texts == "string")
  460. texts = texts.replace(`%${i + 1}`, ins);
  461. });
  462. return texts;
  463. };
  464. if(fileFormat != "xml")
  465. {
  466. errObj = {
  467. "error": true,
  468. "internalError": errFromRegistry.errorInternal,
  469. "code": errorCode,
  470. "message": insArgs(errFromRegistry.errorMessage[lang], args) || insArgs(errFromRegistry.errorMessage[settings.languages.defaultLanguage], args),
  471. "causedBy": insArgs(errFromRegistry.causedBy[lang], args) || insArgs(errFromRegistry.causedBy[settings.languages.defaultLanguage], args),
  472. "timestamp": new Date().getTime()
  473. }
  474. }
  475. else if(fileFormat == "xml")
  476. {
  477. errObj = {
  478. "error": true,
  479. "internalError": errFromRegistry.errorInternal,
  480. "code": errorCode,
  481. "message": insArgs(errFromRegistry.errorMessage[lang], args) || insArgs(errFromRegistry.errorMessage[settings.languages.defaultLanguage], args),
  482. "causedBy": {"cause": insArgs(errFromRegistry.causedBy[lang], args) || insArgs(errFromRegistry.causedBy[settings.languages.defaultLanguage], args)},
  483. "timestamp": new Date().getTime()
  484. }
  485. }
  486. if(!jsl.isEmpty(errorMessage))
  487. errObj.additionalInfo = errorMessage;
  488. let converted = convertFileFormat.auto(fileFormat, errObj, lang).toString();
  489. return pipeString(res, converted, parseURL.getMimeTypeFromFileFormatString(fileFormat), typeof responseCode === "number" ? responseCode : 500);
  490. }
  491. catch(err)
  492. {
  493. 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}`;
  494. return pipeString(res, errMsg, "text/plain", responseCode);
  495. }
  496. };
  497. /**
  498. * Responds with an error page (which one is based on the status code).
  499. * Defaults to 500
  500. * @param {http.ServerResponse} res
  501. * @param {(404|500)} [statusCode=500] HTTP status code - defaults to 500
  502. * @param {String} [error] Additional error message that gets added to the "API-Error" response header
  503. */
  504. const respondWithErrorPage = (res, statusCode, error) => {
  505. statusCode = parseInt(statusCode);
  506. if(isNaN(statusCode))
  507. {
  508. statusCode = 500;
  509. error += ((!jsl.isEmpty(error) ? " - Ironically, an additional " : "An ") + "error was encountered while sending this error page: \"statusCode is not a number (in: httpServer.respondWithErrorPage)\"");
  510. }
  511. if(!jsl.isEmpty(error))
  512. {
  513. res.setHeader("Set-Cookie", `errorInfo=${JSON.stringify({"API-Error-Message": error, "API-Error-StatusCode": statusCode})}`);
  514. res.setHeader("API-Error", error);
  515. }
  516. return pipeFile(res, settings.documentation.errorPagePath, "text/html", statusCode);
  517. }
  518. //#MARKER response piping
  519. /**
  520. * Pipes a string into a HTTP response
  521. * @param {http.ServerResponse} res The HTTP res object
  522. * @param {String} text The response body
  523. * @param {String} mimeType The MIME type to respond with
  524. * @param {Number} [statusCode=200] The status code to respond with - defaults to 200
  525. */
  526. const pipeString = (res, text, mimeType, statusCode = 200) => {
  527. try
  528. {
  529. statusCode = parseInt(statusCode);
  530. if(isNaN(statusCode))
  531. throw new Error("Invalid status code");
  532. }
  533. catch(err)
  534. {
  535. res.writeHead(500, {"Content-Type": `text/plain; charset=UTF-8`});
  536. res.end("INTERNAL_ERR:STATUS_CODE_NOT_INT");
  537. return;
  538. }
  539. let s = new Readable();
  540. s._read = () => {};
  541. s.push(text);
  542. s.push(null);
  543. if(!res.writableEnded)
  544. {
  545. s.pipe(res);
  546. if(!res.headersSent)
  547. {
  548. res.writeHead(statusCode, {
  549. "Content-Type": `${mimeType}; charset=UTF-8`,
  550. "Content-Length": byteLength(text) // Content-Length needs the byte length, not the char length
  551. });
  552. }
  553. }
  554. }
  555. /**
  556. * Pipes a file into a HTTP response
  557. * @param {http.ServerResponse} res The HTTP res object
  558. * @param {String} filePath Path to the file to respond with - relative to the project root directory
  559. * @param {String} mimeType The MIME type to respond with
  560. * @param {Number} [statusCode=200] The status code to respond with - defaults to 200
  561. */
  562. const pipeFile = (res, filePath, mimeType, statusCode = 200) => {
  563. try
  564. {
  565. statusCode = parseInt(statusCode);
  566. if(isNaN(statusCode))
  567. throw new Error("err_statuscode_isnan");
  568. }
  569. catch(err)
  570. {
  571. return respondWithErrorPage(res, 500, `Encountered internal server error while piping file: wrong type for status code.`);
  572. }
  573. if(!fs.existsSync(filePath))
  574. return respondWithErrorPage(res, 404, `Internal error: file at "${filePath}" not found.`);
  575. try
  576. {
  577. if(!res.headersSent)
  578. {
  579. res.writeHead(statusCode, {
  580. "Content-Type": `${mimeType}; charset=UTF-8`,
  581. "Content-Length": fs.statSync(filePath).size
  582. });
  583. }
  584. let readStream = fs.createReadStream(filePath);
  585. readStream.pipe(res);
  586. }
  587. catch(err)
  588. {
  589. logger("fatal", err, true);
  590. }
  591. }
  592. //#MARKER serve docs
  593. /**
  594. * Serves the documentation page
  595. * @param {http.IncomingMessage} req The HTTP req object
  596. * @param {http.ServerResponse} res The HTTP res object
  597. */
  598. const serveDocumentation = (req, res) => {
  599. let resolvedURL = parseURL(req.url);
  600. if(!lists.isConsoleBlacklisted(resolveIP(req)))
  601. {
  602. logRequest("docs", null, {
  603. ipAddress: resolveIP(req),
  604. urlParameters: resolvedURL.queryParams,
  605. urlPath: resolvedURL.pathArray
  606. });
  607. }
  608. let selectedEncoding = getAcceptedEncoding(req);
  609. let fileExtension = "";
  610. if(selectedEncoding != null)
  611. fileExtension = `.${getFileExtensionFromEncoding(selectedEncoding)}`;
  612. debug("HTTP", `Serving docs with encoding "${selectedEncoding}"`);
  613. let filePath = `${settings.documentation.compiledPath}documentation.html${fileExtension}`;
  614. let fallbackPath = `${settings.documentation.compiledPath}documentation.html`;
  615. fs.exists(filePath, exists => {
  616. if(exists)
  617. {
  618. if(selectedEncoding == null)
  619. selectedEncoding = "identity"; // identity = no encoding (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding)
  620. res.setHeader("Content-Encoding", selectedEncoding);
  621. return pipeFile(res, filePath, "text/html", 200);
  622. }
  623. else
  624. return pipeFile(res, fallbackPath, "text/html", 200);
  625. });
  626. }
  627. //#MARKER util
  628. /**
  629. * Returns the name of the client's accepted encoding with the highest priority
  630. * @param {http.IncomingMessage} req The HTTP req object
  631. * @returns {null|"gzip"|"deflate"|"br"} Returns null if no encodings are supported, else returns the encoding name
  632. */
  633. const getAcceptedEncoding = req => {
  634. let selectedEncoding = null;
  635. let encodingPriority = [];
  636. settings.httpServer.encodings.brotli && encodingPriority.push("br");
  637. settings.httpServer.encodings.gzip && encodingPriority.push("gzip");
  638. settings.httpServer.encodings.deflate && encodingPriority.push("deflate");
  639. encodingPriority = encodingPriority.reverse();
  640. let acceptedEncodings = [];
  641. if(req.headers["accept-encoding"])
  642. acceptedEncodings = req.headers["accept-encoding"].split(/\s*[,]\s*/gm);
  643. acceptedEncodings = acceptedEncodings.reverse();
  644. encodingPriority.forEach(encPrio => {
  645. if(acceptedEncodings.includes(encPrio))
  646. selectedEncoding = encPrio;
  647. });
  648. return selectedEncoding;
  649. }
  650. /**
  651. * Returns the length of a string in bytes
  652. * @param {String} str
  653. * @returns {Number}
  654. */
  655. function byteLength(str)
  656. {
  657. if(!str)
  658. return 0;
  659. return Buffer.byteLength(str, "utf8");
  660. }
  661. /**
  662. * Returns the file extension for the provided encoding (without dot prefix)
  663. * @param {null|"gzip"|"deflate"|"br"} encoding
  664. * @returns {String}
  665. */
  666. const getFileExtensionFromEncoding = encoding => {
  667. switch(encoding)
  668. {
  669. case "gzip":
  670. return "gz";
  671. case "deflate":
  672. return "zz";
  673. case "br":
  674. case "brotli":
  675. return "br";
  676. default:
  677. return "";
  678. }
  679. }
  680. /**
  681. * Tries to serve data with an encoding supported by the client, else just serves the raw data
  682. * @param {http.IncomingMessage} req The HTTP req object
  683. * @param {http.ServerResponse} res The HTTP res object
  684. * @param {String} data The data to send to the client
  685. * @param {String} mimeType The MIME type to respond with
  686. */
  687. function tryServeEncoded(req, res, data, mimeType)
  688. {
  689. let selectedEncoding = getAcceptedEncoding(req);
  690. debug("HTTP", `Trying to serve with encoding ${selectedEncoding}`);
  691. if(selectedEncoding)
  692. res.setHeader("Content-Encoding", selectedEncoding);
  693. else
  694. res.setHeader("Content-Encoding", "identity");
  695. switch(selectedEncoding)
  696. {
  697. case "br":
  698. if(!semver.lt(process.version, "v11.7.0")) // Brotli was added in Node v11.7.0
  699. {
  700. zlib.brotliCompress(data, (err, encRes) => {
  701. if(!err)
  702. return pipeString(res, encRes, mimeType);
  703. else
  704. return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
  705. });
  706. }
  707. else
  708. {
  709. res.setHeader("Content-Encoding", "identity");
  710. return pipeString(res, data, mimeType);
  711. }
  712. break;
  713. case "gzip":
  714. zlib.gzip(data, (err, encRes) => {
  715. if(!err)
  716. return pipeString(res, encRes, mimeType);
  717. else
  718. return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
  719. });
  720. break;
  721. case "deflate":
  722. zlib.deflate(data, (err, encRes) => {
  723. if(!err)
  724. return pipeString(res, encRes, mimeType);
  725. else
  726. return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
  727. });
  728. break;
  729. default:
  730. res.setHeader("Content-Encoding", "identity");
  731. return pipeString(res, data, mimeType);
  732. }
  733. }
  734. module.exports = { init, respondWithError, respondWithErrorPage, pipeString, pipeFile, serveDocumentation, getAcceptedEncoding, getFileExtensionFromEncoding, tryServeEncoded };