docs.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. // this module initializes the blacklist, whitelist and console blacklist
  2. const scl = require("svcorelib");
  3. // const farmhash = require("farmhash");
  4. const fs = require("fs-extra");
  5. const settings = require("../settings");
  6. const debug = require("./verboseLogging");
  7. const packageJSON = require("../package.json");
  8. const parseJokes = require("./parseJokes");
  9. const logRequest = require("./logRequest");
  10. const zlib = require("zlib");
  11. const xss = require("xss");
  12. const semver = require("semver");
  13. const analytics = require("./analytics");
  14. const languages = require("./languages");
  15. const path = require("path");
  16. /**
  17. * Initializes the documentation files
  18. * @returns {Promise}
  19. */
  20. function init()
  21. {
  22. return new Promise((resolve, reject) => {
  23. try
  24. {
  25. process.injectionCounter = 0;
  26. debug("Docs", "Starting daemon and recompiling documentation files...")
  27. startDaemon();
  28. recompileDocs();
  29. return resolve();
  30. }
  31. catch(err)
  32. {
  33. return reject(err);
  34. }
  35. });
  36. }
  37. /**
  38. * Starts a daemon in the docs folder that awaits changes and then recompiles the docs
  39. */
  40. function startDaemon()
  41. {
  42. // See https://github.com/Sv443/SvCoreLib/issues/6 on why I set the blacklist pattern to [ "**/**/invalid" ]
  43. let fd = new scl.FolderDaemon(path.resolve(settings.documentation.rawDirPath), [ "**/path/that_doesnt/exist/*" ], true, settings.documentation.daemonInterval * 1000);
  44. fd.onChanged((error, result) => {
  45. scl.unused(result);
  46. if(!error)
  47. {
  48. debug("Daemon", "Noticed changed files");
  49. logRequest("docsrecompiled");
  50. recompileDocs();
  51. }
  52. });
  53. // See also https://github.com/Sv443/SvCoreLib/issues/7 (why does software break smh)
  54. // old code in case of an emergency:
  55. // let oldChecksum = "";
  56. // let newChecksum = "";
  57. // const scanDir = () => {
  58. // fs.readdir(settings.documentation.rawDirPath, (err, files) => {
  59. // if(err)
  60. // return console.log(`${scl.colors.fg.red}Daemon got error: ${err}${scl.colors.rst}\n`);
  61. // let checksum = "";
  62. // files.forEach((file, i) => {
  63. // checksum += (i != 0 && i < files.length ? "-" : "") + farmhash.hash32(fs.readFileSync(`${settings.documentation.rawDirPath}${file}`)).toString();
  64. // });
  65. // newChecksum = checksum;
  66. // if(scl.isEmpty(oldChecksum))
  67. // oldChecksum = checksum;
  68. // if(oldChecksum != newChecksum)
  69. // {
  70. // debug("Daemon", "Noticed changed files");
  71. // logRequest("docsrecompiled");
  72. // recompileDocs();
  73. // }
  74. // oldChecksum = checksum;
  75. // });
  76. // };
  77. // if(scl.isEmpty(process.jokeapi.documentation))
  78. // process.jokeapi.documentation = {};
  79. // process.jokeapi.documentation.daemonInterval = setInterval(() => scanDir(), settings.documentation.daemonInterval * 1000);
  80. // scanDir();
  81. }
  82. /**
  83. * Recompiles the documentation page
  84. */
  85. function recompileDocs()
  86. {
  87. debug("Docs", "Recompiling docs...");
  88. try
  89. {
  90. let filesToInject = [
  91. `${settings.documentation.rawDirPath}index.js`,
  92. `${settings.documentation.rawDirPath}index.css`,
  93. `${settings.documentation.rawDirPath}index.html`,
  94. `${settings.documentation.rawDirPath}errorPage.css`,
  95. `${settings.documentation.rawDirPath}errorPage.js`
  96. ];
  97. let injectedFileNames = [
  98. `${settings.documentation.compiledPath}index_injected.js`,
  99. `${settings.documentation.compiledPath}index_injected.css`,
  100. `${settings.documentation.compiledPath}documentation.html`,
  101. `${settings.documentation.compiledPath}errorPage_injected.css`,
  102. `${settings.documentation.compiledPath}errorPage_injected.js`
  103. ];
  104. let promises = [];
  105. process.injectionCounter = 0;
  106. process.injectionTimestamp = new Date().getTime();
  107. filesToInject.forEach((fti, i) => {
  108. promises.push(new Promise((resolve, reject) => {
  109. scl.unused(reject);
  110. inject(fti).then((injected, injectionsNum) => {
  111. if(!scl.isEmpty(injectionsNum) && !isNaN(parseInt(injectionsNum)))
  112. process.injectionCounter += parseInt(injectionsNum);
  113. process.brCompErrOnce = false;
  114. if(settings.httpServer.encodings.gzip)
  115. saveEncoded("gzip", injectedFileNames[i], injected).catch(err => scl.unused(err));
  116. if(settings.httpServer.encodings.deflate)
  117. saveEncoded("deflate", injectedFileNames[i], injected).catch(err => scl.unused(err));
  118. if(settings.httpServer.encodings.brotli)
  119. {
  120. saveEncoded("brotli", injectedFileNames[i], injected).catch(err => {
  121. scl.unused(err);
  122. if(!process.brCompErrOnce)
  123. {
  124. process.brCompErrOnce = true;
  125. injectError(`Brotli compression is only supported since Node.js version 11.7.0 - current Node.js version is ${semver.clean(process.version)}`, false);
  126. }
  127. });
  128. }
  129. fs.writeFile(injectedFileNames[i], injected, err => {
  130. if(err)
  131. injectError(err);
  132. return resolve();
  133. });
  134. });
  135. }));
  136. });
  137. Promise.all(promises).then(() => {
  138. debug("Docs", `Done recompiling docs in ${scl.colors.fg.yellow}${new Date().getTime() - process.injectionTimestamp}ms${scl.colors.rst}, injected ${scl.colors.fg.yellow}${process.injectionCounter}${scl.colors.rst} values`);
  139. }).catch(err => {
  140. console.log(`Injection error: ${err}`);
  141. });
  142. }
  143. catch(err)
  144. {
  145. injectError(err);
  146. }
  147. }
  148. /**
  149. * Asynchronously encodes a string and saves it encoded with the selected encoding
  150. * @param {("gzip"|"deflate"|"brotli")} encoding The encoding method
  151. * @param {String} filePath The path to a file to save the encoded string to - respective file extensions will automatically be added
  152. * @param {String} content The string to encode
  153. * @returns {Promise<null|String>} Returns a Promise. Resolve contains no parameters, reject contains error message as a string
  154. */
  155. function saveEncoded(encoding, filePath, content)
  156. {
  157. return new Promise((resolve, reject) => {
  158. switch(encoding)
  159. {
  160. case "gzip":
  161. zlib.gzip(content, (err, res) => {
  162. if(!err)
  163. {
  164. fs.writeFile(`${filePath}.gz`, res, err => {
  165. if(!err)
  166. return resolve();
  167. else return reject(err);
  168. });
  169. }
  170. else return reject(err);
  171. });
  172. break;
  173. case "deflate":
  174. zlib.deflate(content, (err, res) => {
  175. if(!err)
  176. {
  177. fs.writeFile(`${filePath}.zz`, res, err => {
  178. if(!err)
  179. return resolve();
  180. else return reject(err);
  181. });
  182. }
  183. else return reject(err);
  184. });
  185. break;
  186. case "brotli":
  187. if(!semver.lt(process.version, "v11.7.0")) // Brotli was added in Node v11.7.0
  188. {
  189. zlib.brotliCompress(content, (err, res) => {
  190. if(!err)
  191. {
  192. fs.writeFile(`${filePath}.br`, res, err => {
  193. if(!err)
  194. return resolve();
  195. else return reject(err);
  196. });
  197. }
  198. else return reject(err);
  199. });
  200. }
  201. else return reject(`Brotli compression is only supported since Node.js version "v11.7.0" - current Node.js version is "${process.version}"`);
  202. break;
  203. default:
  204. return reject(`Encoding method "${encoding}" not found - valid methods are: "gzip", "deflate", "brotli"`);
  205. }
  206. });
  207. }
  208. /**
  209. * Logs an injection error to the console
  210. * @param {String} err The error message
  211. * @param {Boolean} [exit=true] Whether or not to exit the process with code 1 - default: true
  212. */
  213. function injectError(err, exit = true)
  214. {
  215. console.log(`\n${scl.colors.fg.red}Error while injecting values into docs: ${err}${scl.colors.rst}\n`);
  216. analytics({
  217. type: "Error",
  218. data: {
  219. errorMessage: `Error while injecting into documentation: ${err}`,
  220. ipAddress: `N/A`,
  221. urlPath: [],
  222. urlParameters: {}
  223. }
  224. })
  225. if(exit)
  226. process.exit(1);
  227. }
  228. /**
  229. * Injects all constants, external files and values into the passed file
  230. * @param {String} filePath Path to the file to inject things into
  231. * @returns {Promise<String, Number>} Returns the finished file content as passed argument in a promise
  232. */
  233. function inject(filePath)
  234. {
  235. return new Promise((resolve, reject) => {
  236. fs.readFile(filePath, (err, file) => {
  237. if(err)
  238. return reject(err);
  239. try
  240. {
  241. file = file.toString();
  242. //#SECTION INSERTs
  243. const contributors = JSON.stringify(packageJSON.contributors);
  244. const jokeCount = parseJokes.jokeCount;
  245. const injections = {
  246. "%#INSERT:VERSION#%": settings.info.version,
  247. "%#INSERT:NAME#%": settings.info.name.toString(),
  248. "%#INSERT:DESC#%": settings.info.desc.toString(),
  249. "%#INSERT:AUTHORWEBSITEURL#%": settings.info.author.website.toString(),
  250. "%#INSERT:AUTHORGITHUBURL#%": settings.info.author.github.toString(),
  251. "%#INSERT:CONTRIBUTORS#%": (!scl.isEmpty(contributors) ? contributors : "{}"),
  252. "%#INSERT:CONTRIBUTORGUIDEURL#%": settings.info.contribGuideUrl.toString(),
  253. "%#INSERT:PROJGITHUBURL#%": settings.info.projGitHub.toString(),
  254. "%#INSERT:JOKESUBMISSIONURL#%": settings.jokes.jokeSubmissionURL.toString(),
  255. "%#INSERT:CATEGORYARRAY#%": JSON.stringify([settings.jokes.possible.anyCategoryName, ...settings.jokes.possible.categories]),
  256. "%#INSERT:FLAGSARRAY#%": JSON.stringify(settings.jokes.possible.flags),
  257. "%#INSERT:FILEFORMATARRAY#%": JSON.stringify(settings.jokes.possible.formats.map(itm => itm.toUpperCase())),
  258. "%#INSERT:TOTALJOKES#%": (!scl.isEmpty(jokeCount) ? jokeCount.toString() : 0),
  259. "%#INSERT:TOTALJOKESZEROINDEXED#%": (!scl.isEmpty(jokeCount) ? (jokeCount - 1).toString() : 0),
  260. "%#INSERT:PRIVACYPOLICYURL#%": settings.info.privacyPolicyUrl.toString(),
  261. "%#INSERT:DOCSURL#%": (!scl.isEmpty(settings.info.docsURL) ? settings.info.docsURL : "(Error: Documentation URL not defined)"),
  262. "%#INSERT:RATELIMITCOUNT#%": settings.httpServer.rateLimiting.toString(),
  263. "%#INSERT:FORMATVERSION#%": settings.jokes.jokesFormatVersion.toString(),
  264. "%#INSERT:MAXPAYLOADSIZE#%": settings.httpServer.maxPayloadSize.toString(),
  265. "%#INSERT:MAXURLLENGTH#%": settings.httpServer.maxUrlLength.toString(),
  266. "%#INSERT:JOKELANGCOUNT#%": languages.jokeLangs().length.toString(),
  267. "%#INSERT:SYSLANGCOUNT#%": languages.systemLangs().length.toString(),
  268. "%#INSERT:MAXJOKEAMOUNT#%": settings.jokes.maxAmount.toString(),
  269. "%#INSERT:JOKEENCODEAMOUNT#%": settings.jokes.encodeAmount.toString(),
  270. "%#INSERT:SUBMISSIONRATELIMIT#%": settings.jokes.submissions.rateLimiting.toString(),
  271. "%#INSERT:CATEGORYALIASES#%": JSON.stringify(settings.jokes.possible.categoryAliases),
  272. "%#INSERT:LASTMODIFIEDISO#%": new Date().toISOString().trim(),
  273. };
  274. const checkMatch = (key, regex) => {
  275. allMatches += ((file.toString().match(regex) || []).length || 0);
  276. let injection = sanitize(injections[key]);
  277. file = file.replace(regex, !scl.isEmpty(injection) ? injection : "Error");
  278. };
  279. let allMatches = 0;
  280. Object.keys(injections).forEach(key => {
  281. checkMatch(key, new RegExp(`<${key}>`, "gm")); // style: <%#INSERT:XY#%>
  282. checkMatch(key, new RegExp(`<!--${key}-->`, "gm")); // style: <!--%#INSERT:XY#%-->
  283. });
  284. if(isNaN(parseInt(allMatches)))
  285. allMatches = 0;
  286. process.injectionCounter += allMatches;
  287. return resolve(file.toString());
  288. }
  289. catch(err)
  290. {
  291. return reject(err);
  292. }
  293. });
  294. });
  295. }
  296. /**
  297. * Sanitizes a string to prevent XSS
  298. * @param {String} str
  299. * @returns {String}
  300. */
  301. function sanitize(str)
  302. {
  303. return xss(str);
  304. }
  305. /**
  306. * Removes all line breaks and tab stops from an input string and returns it
  307. * @param {String} input
  308. * @returns {String}
  309. */
  310. function minify(input)
  311. {
  312. return input.toString().replace(/(\n|\r\n|\t)/gm, "");
  313. }
  314. module.exports = { init, recompileDocs, minify, sanitize };