Browse Source

feat: push more

Sv443 5 months ago
parent
commit
dc8b06fb76

+ 260 - 0
src/analytics.js

@@ -0,0 +1,260 @@
+const http = require("http");
+const jsl = require("svjsl");
+const sql = require("mysql");
+const fs = require("fs-extra");
+const logger = require("./logger");
+const settings = require("../settings");
+const debug = require("./verboseLogging");
+jsl.unused(http);
+
+module.exports.connectionInfo = {
+    connected: false,
+    info: ""
+};
+
+
+/**
+ * Initializes the Analytics module by setting up the MySQL connection
+ * @returns {Promise} Returns a promise
+ */
+const init = () => {
+    return new Promise((resolve, reject) => {
+        if(!settings.analytics.enabled)
+            return resolve();
+
+        let sqlConnection = sql.createConnection({
+            host: settings.sql.host,
+            user: (process.env["DB_USERNAME"] || ""),
+            password: (process.env["DB_PASSWORD"] || ""),
+            database: settings.sql.database,
+            port: settings.sql.port
+        });
+
+        sqlConnection.connect(err => {
+            if(err)
+            {
+                debug("SQL", `Error while connecting to DB: ${err}`);
+                return reject(`${err}\nMaybe the database server isn't running or doesn't allow the connection.\nAlternatively, set the property "analytics.enabled" in the file "settings.js" to "false"`);
+            }
+            else
+            {
+                debug("SQL", `Successfully connected to database at ${settings.sql.host}:${settings.sql.port}/${settings.sql.database}`);
+
+                this.sqlConn = sqlConnection;
+                module.exports.sqlConn = sqlConnection;
+
+                sendQuery("SHOW TABLES LIKE \"analytics\"").then(res => {
+                    if(typeof res != "object" || res.length <= 0)
+                    {
+                        debug("SQL", `DB table doesn't exist, creating it...`);
+                        let createAnalyticsTableQuery = fs.readFileSync(`${settings.analytics.dirPath}create_analytics.sql`).toString();
+                        sendQuery(createAnalyticsTableQuery).then(() => {
+                            module.exports.connectionInfo = {
+                                connected: true,
+                                info: `${settings.sql.host}:${settings.sql.port}/${settings.sql.database}`
+                            };
+                            debug("SQL", `Successfully created analytics DB, analytics init is done`);
+                            return resolve();
+                        }).catch(err => {
+                            debug("SQL", `Error while creating DB table: ${err}`);
+                            return reject(`${err}\nMaybe the database server isn't running or doesn't allow the connection.\nAlternatively, set the property "analytics.enabled" in the file "settings.js" to "false"`);
+                        });
+                    }
+                    else
+                    {
+                        debug("SQL", `DB table exists, analytics init is done`);
+                        module.exports.connectionInfo = {
+                            connected: true,
+                            info: `${settings.sql.host}:${settings.sql.port}/${settings.sql.database}`
+                        };
+                        return resolve();
+                    }
+                }).catch(err => {
+                    debug("SQL", `Error while detecting analytics table in DB: ${err}`);
+                    return reject(`${err}\nMaybe the database server isn't running or doesn't allow the connection.\nAlternatively, set the property "analytics.enabled" in the file "settings.js" to "false"`);
+                });
+            }
+        });
+
+        sqlConnection.on("error", err => {
+            logger("error", `SQL connection error: ${err}`, true);
+        });
+    });
+};
+
+/**
+ * Ends the SQL connection
+ * @returns {Promise}
+ */
+const endSqlConnection = () => {
+    return new Promise((resolve) => {
+        this.sqlConn.end(err => {
+            if(err)
+                this.sqlConn.destroy();
+            resolve();
+        });
+    });
+};
+
+/**
+ * Sends a formatted (SQLI-protected) query
+ * @param {String} query The SQL query with question marks where the values are
+ * @param {Array<String>} insertValues The values to insert into the question marks - use the primitive type null for an empty value
+ * @returns {Promise} Returns a Promise - resolves with the query results or rejects with the error string
+ */
+const sendQuery = (query, insertValues) => {
+    return new Promise((resolve, reject) => {
+        if(jsl.isEmpty(this.sqlConn) || (this.sqlConn && this.sqlConn.state != "connected" && this.sqlConn.state != "authenticated"))
+            return reject(`DB connection is not established yet. Current connection state is "${this.sqlConn.state || "disconnected"}"`);
+
+        debug("SQL", `Sending query: "${query.replace(/"/g, "'")}" with values "${(typeof insertValues == "object") ? insertValues.map((v) => (v == null ? "NULL" : v)).join(",").replace(/"/g, "'") : "(empty)"}"`);
+
+        this.sqlConn.query({
+            sql: (typeof insertValues == "object" && insertValues.length > 0) ? this.sqlConn.format(query, insertValues) : query,
+            timeout: settings.sql.timeout * 1000
+        }, (err, result) => {
+            if(err)
+            {
+                debug("SQL", `Error while sending query: ${err}`);
+                return reject(err);
+            }
+            else
+            {
+                try
+                {
+                    if(resolve)
+                        return resolve(JSON.parse(JSON.stringify(result)));
+                }
+                catch(err)
+                {
+                    debug("SQL", `Error while sending query: ${err}`);
+                    return reject(err);
+                }
+            }
+        });
+    });
+};
+
+/**
+ * @typedef {Object} AnalyticsSuccessfulRequest
+ * @prop {("SuccessfulRequest")} type
+ * @prop {Object} data
+ * @prop {String} data.ipAddress
+ * @prop {Array<String>} data.urlPath
+ * @prop {Object} data.urlParameters
+ */
+
+/**
+ * @typedef {Object} AnalyticsDocsRequest
+ * @prop {("Docs")} type
+ * @prop {Object} data
+ * @prop {String} data.ipAddress
+ * @prop {Array<String>} data.urlPath
+ * @prop {Object} data.urlParameters
+ */
+
+/**
+ * @typedef {Object} AnalyticsRateLimited
+ * @prop {("RateLimited")} type
+ * @prop {Object} data
+ * @prop {String} data.ipAddress
+ * @prop {Array<String>} data.urlPath
+ * @prop {Object} data.urlParameters
+ */
+
+/**
+ * @typedef {Object} AnalyticsError
+ * @prop {("Error")} type
+ * @prop {Object} data
+ * @prop {String} data.ipAddress
+ * @prop {Array<String>} data.urlPath
+ * @prop {Object} data.urlParameters
+ * @prop {String} data.errorMessage
+ */
+
+/**
+ * @typedef {Object} AnalyticsSubmission
+ * @prop {("JokeSubmission")} type
+ * @prop {Object} data
+ * @prop {String} data.ipAddress
+ * @prop {Array<String>} data.urlPath
+ * @prop {Object} data.urlParameters
+ * @prop {Object} data.submission
+ */
+
+/**
+ * @typedef {Object} AnalyticsTokenIncluded
+ * @prop {("AuthTokenIncluded")} type
+ * @prop {Object} data
+ * @prop {String} data.ipAddress
+ * @prop {Array<String>} data.urlPath
+ * @prop {Object} data.urlParameters
+ * @prop {String} data.submission
+ */
+
+/**
+ * Logs something to the analytics database
+ * @param {(AnalyticsDocsRequest|AnalyticsSuccessfulRequest|AnalyticsRateLimited|AnalyticsError|AnalyticsSubmission|AnalyticsTokenIncluded)} analyticsDataObject The analytics data
+ * @returns {(Boolean|String)} Returns a string containing an error message if errored, else returns true
+ */
+const logAnalytics = analyticsDataObject => {
+    try
+    {
+        let type = analyticsDataObject.type;
+
+        if(!settings.analytics.enabled)
+            return true;
+        
+        if(jsl.isEmpty(this.sqlConn) || (this.sqlConn && this.sqlConn.state != "connected" && this.sqlConn.state != "authenticated"))
+        {
+            debug("Analytics", `Error while logging some analytics data - SQL connection state is invalid: ${this.sqlConn.state || "disconnected"}`);
+            return `DB connection is not established yet. Current connection state is "${this.sqlConn.state || "disconnected"}"`;
+        }
+
+        let writeObject = {
+            type: type,
+            ipAddress: analyticsDataObject.data.ipAddress || null,
+            urlPath: (analyticsDataObject.data.urlPath != null ? JSON.stringify(analyticsDataObject.data.urlPath) : null) || null,
+            urlParameters: (analyticsDataObject.data.urlParameters != null ? JSON.stringify(analyticsDataObject.data.urlParameters) : null) || null,
+            errorMessage: analyticsDataObject.data.errorMessage || null,
+            submission: (analyticsDataObject.data.submission != null ? JSON.stringify(analyticsDataObject.data.submission) : null) || null
+        };
+        
+        if(!["Docs", "SuccessfulRequest", "RateLimited", "Error", "JokeSubmission"].includes(type))
+            return `Analytics log type "${type}" is invalid`;
+
+        sendQuery("INSERT INTO ?? (aID, aType, aIpAddress, aUrlPath, aUrlParameters, aErrorMessage, aSubmission, aTimestamp) VALUES (NULL, ?, ?, ?, ?, ?, ?, NULL)", [
+            settings.analytics.sqlTableName,
+            writeObject.type,
+            writeObject.ipAddress,
+            writeObject.urlPath,
+            writeObject.urlParameters,
+            writeObject.errorMessage,
+            writeObject.submission
+        ]).then(() => {
+            debug("Analytics", `Successfully logged some analytics data to the DB`);
+        }).catch(err => {
+            debug("Analytics", `Error while logging some analytics data - query returned error: ${err}`);
+            return logger("error", `Error while saving analytics data to database - Error: ${err}\nAnalytics Data: ${JSON.stringify(writeObject)}`, true);
+        });
+    }
+    catch(err)
+    {
+        debug("Analytics", `General error while preparing analytics data: ${err}`);
+        return logger("error", `Error while preparing analytics data - Error: ${err}`, true);
+    }
+};
+
+/**
+ * idk why this function exists, maybe I'll remember later on
+ */
+function rateLimited()
+{
+    return;
+}
+
+module.exports = logAnalytics;
+module.exports.init = init;
+module.exports.sendQuery = sendQuery;
+module.exports.endSqlConnection = endSqlConnection;
+module.exports.rateLimited = rateLimited;

+ 102 - 0
src/auth.js

@@ -0,0 +1,102 @@
+const http = require("http");
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+const crypto = require("crypto");
+const settings = require("../settings");
+
+jsl.unused([http]);
+
+
+var previousDaemonHash;
+var tokenList;
+
+/**
+ * Initializes the auth module
+ */
+const init = () => {
+    return new Promise(resolve => {
+        fs.exists(settings.auth.tokenListFile, exists => {
+            if(!exists)
+                fs.writeFileSync(settings.auth.tokenListFile, JSON.stringify([], null, 4));
+            
+            refreshTokens();
+            setInterval(() => daemonInterval(), settings.auth.daemonInterval);
+            return resolve();
+        });
+    });
+};
+
+/**
+ * To be called on interval to check if the tokens should be refreshed
+ */
+function daemonInterval()
+{
+    let tokenFileRaw = fs.readFileSync(settings.auth.tokenListFile).toString();
+    let tokenHash = crypto.createHash("md5").update(tokenFileRaw).digest("hex");
+
+    if(previousDaemonHash == undefined)
+        return;
+    else if(previousDaemonHash != tokenHash)
+    {
+        previousDaemonHash = tokenHash;
+        refreshTokens();
+    }
+}
+
+/**
+ * Refreshes the auth tokens in memory
+ */
+function refreshTokens()
+{
+    try
+    {
+        let tokens = JSON.parse(fs.readFileSync(settings.auth.tokenListFile).toString());
+        tokenList = tokens;
+    }
+    catch(err)
+    {
+        tokenList = [];
+        fs.writeFileSync(settings.auth.tokenListFile, JSON.stringify([], null, 4));
+    }
+}
+
+/**
+ * @typedef {Object} Authorization
+ * @prop {Boolean} isAuthorized
+ * @prop {String} token
+ */
+
+/**
+ * Checks if the requester has provided an auth header and if the auth header is valid
+ * @param {http.IncomingMessage} req 
+ * @param {http.ServerResponse} [res] If not provided, users will not get the `Token-Valid` response header
+ * @returns {Authorization}
+ */
+const authByHeader = (req, res) => {
+    let isAuthorized = false;
+    let requestersToken = "";
+
+    if(req.headers && req.headers[settings.auth.tokenHeaderName])
+    {
+        if(Array.isArray(tokenList) && tokenList.length > 0)
+        {
+            tokenList.forEach(tokenObj => {
+                if(tokenObj.token == req.headers[settings.auth.tokenHeaderName].toString())
+                {
+                    requestersToken = req.headers[settings.auth.tokenHeaderName].toString();
+                    isAuthorized = true;
+                }
+            });
+        }
+
+        if(res && typeof res.setHeader == "function")
+            res.setHeader(settings.auth.tokenValidHeader, (isAuthorized ? "1" : "0"));
+    }
+
+    return {
+        isAuthorized: isAuthorized,
+        token: requestersToken
+    };
+};
+
+module.exports = { init, authByHeader };

+ 163 - 0
src/classes/AllJokes.js

@@ -0,0 +1,163 @@
+const jsl = require("svjsl");
+
+const parseJokes = require("../parseJokes");
+const languages = require("../languages");
+
+const settings = require("../../settings");
+
+
+jsl.unused(parseJokes); // only used for typedefs
+
+// expected format:
+/*
+{
+    "en": {
+        info: {
+            formatVersion: 2
+        },
+        jokes: [
+            {
+                (joke)
+            },
+            ...
+        ]
+    },
+    ...
+}
+*/
+
+/**
+ * @typedef {Object} CountPerLangObj
+ * @prop {Number} [en]
+ * @prop {Number} [de]
+ */
+
+/**
+ * @typedef {Object} SafeJokesPerLangObj
+ * @prop {String} lang lang code
+ * @prop {Number} count amount of safe jokes
+ */
+
+class AllJokes
+{
+    /**
+     * Constructs a new AllJokes object. This object contains all methods to get certain jokes
+     * @param {Object} jokeArray 
+     */
+    constructor(jokeArray)
+    {
+        this.jokes = {};
+        let jokeCount = 0;
+        let formatVersions = [];
+        let jokeCountPerLang = {};
+        this._safeJokes = [];
+
+        //#SECTION check validity, get joke count and get format versions
+        Object.keys(jokeArray).forEach(key => {
+            let lValid = languages.isValidLang(key);
+            if(lValid !== true)
+                throw new Error(`Invalid language code in construction of an AllJokes object. Expected valid two character language code - got "${key}": ${lValid}`);
+            
+            let currentLangSafeJokesCount = 0;
+
+            if(!jokeCountPerLang[key])
+                jokeCountPerLang[key] = 0;
+
+            jokeCount += jokeArray[key].jokes.length;
+            jokeCountPerLang[key] += jokeArray[key].jokes.length;
+
+            let fv = jokeArray[key].info.formatVersion;
+
+            // iterates over each joke of the current language
+            jokeArray[key].jokes.forEach((j, i) => {
+                if(j.safe === true)
+                    currentLangSafeJokesCount++;
+
+                jokeArray[key].jokes[i].lang = key;
+            });
+
+            if(fv != settings.jokes.jokesFormatVersion)
+                throw new Error(`Error: Jokes file with language ${key} has the wrong format version. Expected ${settings.jokes.jokesFormatVersion} but got ${fv}`);
+
+            formatVersions.push(fv);
+
+            this._safeJokes.push({
+                lang: key,
+                count: currentLangSafeJokesCount
+            });
+        });
+
+        formatVersions.push(settings.jokes.jokesFormatVersion);
+
+        if(!jsl.allEqual(formatVersions))
+            throw new Error(`Error: One or more of the jokes-xy.json files contain(s) a wrong formatVersion parameter`);
+
+        if(typeof jokeArray != "object" || Array.isArray(jokeArray))
+            throw new Error(`Error while constructing a new AllJokes object: parameter "jokeArray" is invalid`);
+
+        this.jokes = jokeArray;
+        this._jokeCount = jokeCount;
+        this._jokeCountPerLang = jokeCountPerLang;
+        this._formatVersions = formatVersions;
+
+        return this;
+    }
+
+    /**
+     * Returns an array of all jokes of the specified language
+     * @param {String} [langCode="en"] Two character language code
+     * @returns {Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>}
+     */
+    getJokeArray(langCode)
+    {
+        if(languages.isValidLang(langCode) !== true)
+            langCode = settings.languages.defaultLanguage;
+
+        return (typeof this.jokes[langCode] == "object" ? this.jokes[langCode].jokes : []);
+    }
+
+    /**
+     * Returns the joke format version
+     * @param {String} [langCode="en"] Two character language code
+     * @returns {Number|undefined} Returns a number if the format version was set, returns undefined, if not
+     */
+    getFormatVersion(langCode)
+    {
+        if(languages.isValidLang(langCode) !== true)
+            langCode = settings.languages.defaultLanguage;
+        
+        if(typeof this.jokes[langCode] != "object")
+            return undefined;
+        
+        return this.jokes[langCode].info ? this.jokes[langCode].info.formatVersion : undefined;
+    }
+
+    /**
+     * Returns the (human readable / 1-indexed) count of jokes
+     * @returns {Number}
+     */
+    getJokeCount()
+    {
+        return this._jokeCount;
+    }
+
+    /**
+     * Returns an object containing joke counts for every lang code
+     * @returns {CountPerLangObj}
+     */
+    getJokeCountPerLang()
+    {
+        return this._jokeCountPerLang;
+    }
+
+    /**
+     * Returns an object containing the count of safe jokes per language
+     * @returns {SafeJokesPerLangObj[]}
+     */
+    getSafeJokes()
+    {
+        return this._safeJokes;
+    }
+}
+
+module.exports = AllJokes;

+ 546 - 0
src/classes/FilteredJoke.js

@@ -0,0 +1,546 @@
+// filtered joke gets created out of the total-jokes array
+// filters can be applied with setter methods
+// final getter method returns one or multiple jokes that match all filters
+
+const AllJokes = require("./AllJokes");
+const parseJokes = require("../parseJokes");
+const languages = require("../languages");
+const tr = require("../translate");
+const jsl = require("svjsl");
+const settings = require("../../settings");
+
+
+/** @typedef {"nsfw"|"racist"|"sexist"|"religious"|"political"|"explicit"} BlacklistFlags */
+/** @typedef {"Any"|"Programming"|"Miscellaneous"|"Dark"} JokeCategory */
+
+
+jsl.unused(AllJokes);
+
+var _lastIDs = [];
+var _selectionAttempts = 0;
+
+class FilteredJoke
+{
+    //#MARKER constructor
+    /**
+     * Constructs an object of type FilteredJoke
+     * @param {AllJokes} allJokes 
+     */
+    constructor(allJokes)
+    {
+        if(jsl.isEmpty(allJokes))
+            throw new Error(`Error while constructing new FilteredJoke object: parameter "allJokes" is empty`);
+
+        this._allJokes = allJokes;
+        this._filteredJokes = null;
+
+        let idRangePerLang = {};
+
+        Object.keys(parseJokes.jokeCountPerLang).forEach(lc => {
+            idRangePerLang[lc] = [ 0, (parseJokes.jokeCountPerLang[lc] - 1) ];
+        });
+
+        /** Resolved category names (aliases are not allowed here) */
+        this._allowedCategories = [
+            settings.jokes.possible.anyCategoryName.toLowerCase(),
+            ...settings.jokes.possible.categories.map(c => c.toLowerCase())
+        ];
+        this._allowedTypes = [...settings.jokes.possible.types];
+        this._searchString = null;
+        this._idRange = [0, (parseJokes.jokeCountPerLang[settings.languages.defaultLanguage] - 1)];
+        this._idRangePerLang = idRangePerLang;
+        this._flags = [];
+        this._errors = [];
+        this._lang = settings.languages.defaultLanguage;
+        this._amount = 1;
+        this._safeMode = false;
+
+        if(!_lastIDs || !Array.isArray(_lastIDs))
+            _lastIDs = [];
+
+        return this;
+    }
+
+    //#MARKER categories
+    /**
+     * Sets the category / categories a joke can be from
+     * @param {JokeCategory|JokeCategory[]} categories 
+     * @returns {Boolean} Returns true if the category / categories were set successfully, else returns false
+     */
+    setAllowedCategories(categories)
+    {
+        if(!Array.isArray(categories))
+            categories = new Array(categories);
+
+        let allCategories = [
+            settings.jokes.possible.anyCategoryName.toLowerCase(),
+            ...settings.jokes.possible.categories.map(c => c.toLowerCase())
+        ];
+        let catsValid = [];
+
+        if(typeof categories == "object" && categories.length != undefined)
+            categories.forEach(cat => {
+                if(allCategories.includes(cat.toLowerCase()))
+                    catsValid.push(true);
+            });
+
+        if(catsValid.length != categories.length)
+        {
+            this._errors.push("The joke category is invalid");
+            return false;
+        }
+        
+        if((typeof categories == "string" && categories.toLowerCase() == settings.jokes.possible.anyCategoryName.toLowerCase())
+        || (typeof categories != "string" && categories.map(c => c.toLowerCase()).includes(settings.jokes.possible.anyCategoryName.toLowerCase())))
+            categories = [...settings.jokes.possible.categories];
+        
+        this._allowedCategories = categories;
+        return true;
+    }
+
+    /**
+     * Returns the category / categories a joke can be in
+     * @returns {JokeCategory[]}
+     */
+    getAllowedCategories()
+    {
+        return this._allowedCategories.map(c => c.toLowerCase());
+    }
+
+    //#MARKER type
+    /**
+     * Sets which types the joke(s) can be of
+     * @param {"single"|"twopart"} type 
+     * @returns {Boolean} Returns true if the type is valid and could be set, false if not
+     */
+    setAllowedType(type)
+    {
+        if(settings.jokes.possible.types.includes(type))
+        {
+            this._allowedTypes = [type];
+            return true;
+        }
+        else
+        {
+            this._errors.push("The \"type\" parameter is invalid");
+            return false;
+        }
+    }
+
+    /**
+     * Returns the allowed types a joke can be of
+     * @returns {Array<"single"|"twopart">}
+     */
+    getAllowedTypes()
+    {
+        return this._allowedTypes
+    }
+
+    //#MARKER search string
+    /**
+     * Sets a string to serach for in jokes
+     * @param {String} searchString Raw string to search for in the joke - URI components get decoded automatically
+     * @returns {Boolean} Returns true if the search string is a valid string and could be set, false if not
+     */
+    setSearchString(searchString)
+    {
+        if(typeof searchString != "string")
+        {
+            this._errors.push("The \"contains\" parameter is invalid");
+            return false;
+        }
+        
+        try
+        {
+            this._searchString = decodeURIComponent(searchString);
+            return true;
+        }
+        catch(err)
+        {
+            this._errors.push("The URI is malformatted or the \"contains\" parameter isn't correctly percent-encoded");
+            return false;
+        }
+    }
+
+    /**
+     * Returns the set search string
+     * @returns {(String|null)} Returns the search string if it is set, else returns null
+     */
+    getSearchString()
+    {
+        return this._searchString;
+    }
+
+    //#MARKER id
+    /**
+     * The IDs a joke can be of
+     * @param {Number} start
+     * @param {Number} [end] If this is not set, it will default to the same value the param `start` has
+     * @param {String} [lang] Lang code
+     * @returns {Boolean} Returns false if the parameter(s) is/are not of type `number`, else returns true
+     */
+    setIdRange(start, end = null, lang = null)
+    {
+        if(jsl.isEmpty(end))
+            end = start;
+        
+        if(jsl.isEmpty(lang))
+            lang = this.getLanguage() || settings.languages.defaultLanguage;
+
+        if(isNaN(parseInt(start)) || isNaN(parseInt(end)) || typeof start != "number" || typeof end != "number" || jsl.isEmpty(start) || jsl.isEmpty(end))
+        {
+            this._errors.push("The \"idRange\" parameter values are not numbers");
+            return false;
+        }
+
+        if(start < 0 || end > this._idRangePerLang[lang][1])
+        {
+            this._errors.push("The \"idRange\" parameter values are out of range");
+            return false;
+        }
+        
+        this._idRange = [start, end];
+        return true;
+    }
+
+    /**
+     * Returns the current ID range
+     * @returns {Array<Number>} An array containing two numbers (index 0 = start ID, index 1 = end ID)
+     */
+    getIdRange()
+    {
+        return this._idRange;
+    }
+
+    //#MARKER flags
+    /**
+     * Sets the blacklist flags
+     * @param {Array<BlacklistFlags>} flags 
+     * @returns {Boolean} Returns true if the flags were set, false if they are invalid
+     */
+    setBlacklistFlags(flags)
+    {
+        let flagsInvalid = false;
+        flags.forEach(flag => {
+            if(!settings.jokes.possible.flags.includes(flag))
+                flagsInvalid = true;
+        });
+
+        if(flagsInvalid)
+        {
+            this._errors.push("The \"blacklistFlags\" parameter is invalid or contains one or more invalid flags");
+            return false;
+        }
+        
+        this._flags = flags;
+        return true;
+    }
+
+    /**
+     * Returns the set blacklist flags
+     * @returns {Array<BlacklistFlags>}
+     */
+    getBlacklistFlags()
+    {
+        return this._flags;
+    }
+
+    //#MARKER language
+    /**
+     * Sets the language
+     * @param {String} code 
+     * @returns {Boolean} Returns true if the language was set, false if it is invalid
+     */
+    setLanguage(code)
+    {
+        if(languages.isValidLang(code) === true)
+        {
+            this._lang = code;
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns the set language code
+     * @returns {String}
+     */
+    getLanguage()
+    {
+        return this._lang || settings.languages.defaultLanguage;
+    }
+
+    //#MARKER safe mode
+    /**
+     * Sets the safe mode
+     * @param {Boolean} safeModeEnabled 
+     * @returns {Boolean} Returns the new value of the safe mode
+     */
+    setSafeMode(safeModeEnabled)
+    {
+        if(typeof safeModeEnabled == "boolean")
+            this._safeMode = safeModeEnabled;
+        else
+            this._safeMode = false;
+        
+        return this._safeMode;
+    }
+
+    /**
+     * Returns the value of the safe mode
+     * @returns {Boolean}
+     */
+    getSafeMode()
+    {
+        return this._safeMode;
+    }
+
+    //#MARKER amount
+    /**
+     * Sets the amount of jokes
+     * @param {Number} num 
+     * @returns {Boolean|String} Returns true if the amount was set, string containing error if it is invalid
+     */
+    setAmount(num)
+    {
+        num = parseInt(num);
+
+        if(isNaN(num) || num < 1 || num > settings.jokes.maxAmount)
+            return `"num" parameter in FilteredJoke.setAmount() couldn't be resolved to an integer or it is less than 0 or greater than ${settings.jokes.maxAmount}`;
+
+        this._amount = num;
+        return true;
+    }
+
+    /**
+     * Returns the set joke amount or `1` if not yet set
+     * @returns {Number}
+     */
+    getAmount()
+    {
+        return this._amount || 1;
+    }
+
+    //#MARKER apply filters
+    /**
+     * Applies the previously set filters and modifies the `this._filteredJokes` property with the applied filters
+     * @private
+     * @param {String} lang
+     * @returns {Promise}
+     */
+    _applyFilters(lang)
+    {
+        return new Promise((resolve, reject) => {
+            try
+            {
+                this._filteredJokes = [];
+
+                if(!lang)
+                    lang = settings.languages.defaultLanguage;
+
+                this._allJokes.getJokeArray(lang).forEach(joke => {
+                    // iterate over each joke, reading all set filters and thereby checking if it suits the request
+                    // to deny a joke from being served, just return from this callback function
+
+                    //#SECTION safe mode
+                    if(this.getSafeMode() === true) // if safe mode is enabled
+                    {
+                        if(joke.safe !== true) // if joke is not safe, it's invalid
+                            return;
+                        
+                        if(joke.category == "Dark") // if joke is in category "Dark", it's invalid
+                            return;
+                    }
+                    
+                    
+
+                    //#SECTION id range
+                    let idRange = this.getIdRange(lang);
+                    if(joke.id < idRange[0] || joke.id > idRange[1]) // if the joke is not in the specified ID range, it's invalid
+                        return;
+
+                    //#SECTION categories
+                    let cats = this.getAllowedCategories().map(c => c.toLowerCase());
+
+                    if((typeof cats == "object" && !cats.includes(settings.jokes.possible.anyCategoryName.toLowerCase()))
+                    || (typeof cats == "string" && cats != settings.jokes.possible.anyCategoryName.toLowerCase()))
+                    {
+                        if(!cats.includes(joke.category.toLowerCase())) // if possible categories don't contain the requested category, joke is invalid
+                            return;
+                    }
+
+                    //#SECTION flags
+                    let blFlags = this.getBlacklistFlags();
+                    if(!jsl.isEmpty(blFlags))
+                    {
+                        let flagMatches = false;
+                        Object.keys(joke.flags).forEach(flKey => {
+                            if(blFlags.includes(flKey) && joke.flags[flKey] === true)
+                                flagMatches = true;
+                        });
+                        if(flagMatches) // joke has one or more of the set blacklist flags, joke is invalid
+                            return;
+                    }
+                    
+                    //#SECTION type
+                    if(!this.getAllowedTypes().includes(joke.type)) // if joke type doesn't match the requested type(s), joke is invalid
+                        return;
+                    
+                    //#SECTION search string
+                    let searchMatches = false;
+                    if(!jsl.isEmpty(this.getSearchString()))
+                    {
+                        if(joke.type == "single" && joke.joke.toLowerCase().includes(this.getSearchString()))
+                            searchMatches = true;
+                        else if (joke.type == "twopart" && (joke.setup + joke.delivery).toLowerCase().includes(this.getSearchString()))
+                            searchMatches = true;
+                    }
+                    else searchMatches = true;
+
+                    if(!searchMatches) // if the provided search string doesn't match the joke, the joke is invalid
+                        return;
+                    
+                    //#SECTION language
+                    let langCode = this.getLanguage();
+                    if(languages.isValidLang(langCode) !== true)
+                        return; // invalid lang code, joke is invalid
+                    if(joke.lang.toLowerCase() != langCode.toLowerCase())
+                        return; // lang code doesn't match so joke is invalid
+                    
+                    // Note: amount param is used in getJokes()
+
+                    //#SECTION done
+                    this._filteredJokes.push(joke); // joke is valid, push it to the array that gets passed in the resolve() just below
+                });
+
+                return resolve(this._filteredJokes);
+            }
+            catch(err)
+            {
+                return reject(err);
+            }
+        });
+    }
+
+    //#MARKER get joke
+    /**
+     * Applies all filters and returns the final joke
+     * @param {Number} [amount=1] The amount of jokes to return
+     * @returns {Promise<Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>>} Returns a promise containing an array, which in turn contains a single or multiple randomly selected joke/s that match/es the previously set filters. If the filters didn't match, rejects promise.
+     */
+    getJokes(amount = 1)
+    {
+        amount = parseInt(amount);
+        if(isNaN(amount) || jsl.isEmpty(amount))
+            amount = 1;
+        
+        return new Promise((resolve, reject) => {
+            let retJokes = [];
+            let multiSelectLastIDs = [];
+
+            this._applyFilters(this._lang || settings.languages.defaultLanguage).then(filteredJokes => {
+                if(filteredJokes.length == 0 || typeof filteredJokes != "object")
+                {
+                    if(this._errors && this._errors.length > 0)
+                        return reject(this._errors);
+                    else
+                        return reject(tr(this.getLanguage(), "foundNoMatchingJokes"));
+                }
+                
+                if(!_lastIDs || !Array.isArray(_lastIDs))
+                    _lastIDs = [];
+                
+                if(typeof _selectionAttempts != "number")
+                    _selectionAttempts = 0;
+
+                /**
+                 * @param {Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>} jokes 
+                 */
+                let selectRandomJoke = jokes => {
+                    let idx = jsl.randRange(0, (jokes.length - 1));
+                    let selectedJoke = jokes[idx];
+
+                    if(jokes.length > settings.jokes.lastIDsMaxLength && _lastIDs.includes(selectedJoke.id))
+                    {
+                        if(_selectionAttempts > settings.jokes.jokeRandomizationAttempts)
+                            return reject();
+
+                        _selectionAttempts++;
+
+                        jokes.splice(idx, 1); // remove joke that is already contained in _lastIDs
+
+                        return selectRandomJoke(jokes);
+                    }
+                    else
+                    {
+                        _lastIDs.push(selectedJoke.id);
+
+                        if(_lastIDs.length > settings.jokes.lastIDsMaxLength)
+                            _lastIDs.shift();
+
+                        _selectionAttempts = 0;
+
+                        if(!multiSelectLastIDs.includes(selectedJoke.id))
+                        {
+                            multiSelectLastIDs.push(selectedJoke.id);
+                            return selectedJoke;
+                        }
+                        else
+                        {
+                            if(_selectionAttempts > settings.jokes.jokeRandomizationAttempts)
+                                return reject();
+
+                            _selectionAttempts++;
+
+                            jokes.splice(idx, 1); // remove joke that is already contained in _lastIDs
+
+                            return selectRandomJoke(jokes);
+                        }
+                    }
+                };
+
+                if(amount < filteredJokes.length)
+                {
+                    for(let i = 0; i < amount; i++)
+                    {
+                        let rJoke = selectRandomJoke(filteredJokes);
+                        if(rJoke != null)
+                            retJokes.push(rJoke);
+                    }
+                }
+                else retJokes = filteredJokes;
+                
+                // Sort jokes by ID
+                // retJokes.sort((a, b) => {
+                //     if(b.id > a.id)
+                //         return -1;
+                //     else
+                //         return 1;
+                // });
+
+                return resolve(retJokes);
+            }).catch(err => {
+                return reject(err);
+            });
+        });
+    }
+
+    //#MARKER get all jokes
+    /**
+     * Applies all filters and returns an array of all jokes that are viable
+     * @returns {Promise<Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>>} Returns a promise containing a single, randomly selected joke that matches the previously set filters. If the filters didn't match, rejects promise.
+     */
+    getAllJokes()
+    {
+        return new Promise((resolve, reject) => {
+            this._applyFilters(this._lang || settings.languages.defaultLanguage).then(filteredJokes => {
+                return resolve(filteredJokes);
+            }).catch(err => {
+                return reject(err);
+            });
+        });
+    }
+}
+
+module.exports = FilteredJoke;

+ 357 - 0
src/docs.js

@@ -0,0 +1,357 @@
+// this module initializes the blacklist, whitelist and console blacklist
+
+const scl = require("svcorelib");
+// const farmhash = require("farmhash");
+const fs = require("fs-extra");
+const settings = require("../settings");
+const debug = require("./verboseLogging");
+const packageJSON = require("../package.json");
+const parseJokes = require("./parseJokes");
+const logRequest = require("./logRequest");
+const zlib = require("zlib");
+const xss = require("xss");
+const semver = require("semver");
+const analytics = require("./analytics");
+const languages = require("./languages");
+const path = require("path");
+
+
+/**
+ * Initializes the documentation files
+ * @returns {Promise}
+ */
+function init()
+{
+    return new Promise((resolve, reject) => {
+        try
+        {
+            process.injectionCounter = 0;
+            debug("Docs", "Starting daemon and recompiling documentation files...")
+            startDaemon();
+            recompileDocs();
+            return resolve();
+        }
+        catch(err)
+        {
+            return reject(err);
+        }
+    });
+}
+
+/**
+ * Starts a daemon in the docs folder that awaits changes and then recompiles the docs
+ */
+function startDaemon()
+{
+    // See https://github.com/Sv443/SvCoreLib/issues/6 on why I set the blacklist pattern to [ "**/**/invalid" ]
+    let fd = new scl.FolderDaemon(path.resolve(settings.documentation.rawDirPath), [ "**/path/that_doesnt/exist/*" ], true, settings.documentation.daemonInterval * 1000);
+    fd.onChanged((error, result) => {
+        scl.unused(result);
+        if(!error)
+        {
+            debug("Daemon", "Noticed changed files");
+            logRequest("docsrecompiled");
+            recompileDocs();
+        }
+    });
+    // See also https://github.com/Sv443/SvCoreLib/issues/7 (why does software break smh)
+
+
+
+
+
+    // old code in case of an emergency:
+
+    // let oldChecksum = "";
+    // let newChecksum = "";
+
+    // const scanDir = () => {
+    //     fs.readdir(settings.documentation.rawDirPath, (err, files) => {
+    //         if(err)
+    //             return console.log(`${scl.colors.fg.red}Daemon got error: ${err}${scl.colors.rst}\n`);
+
+    //         let checksum = "";
+    //         files.forEach((file, i) => {
+    //             checksum += (i != 0 && i < files.length ? "-" : "") + farmhash.hash32(fs.readFileSync(`${settings.documentation.rawDirPath}${file}`)).toString();
+    //         });
+
+    //         newChecksum = checksum;
+    //         if(scl.isEmpty(oldChecksum))
+    //             oldChecksum = checksum;
+            
+    //         if(oldChecksum != newChecksum)
+    //         {
+    //             debug("Daemon", "Noticed changed files");
+    //             logRequest("docsrecompiled");
+    //             recompileDocs();
+    //         }
+
+    //         oldChecksum = checksum;
+    //     });
+    // };
+
+    // if(scl.isEmpty(process.jokeapi.documentation))
+    //     process.jokeapi.documentation = {};
+    // process.jokeapi.documentation.daemonInterval = setInterval(() => scanDir(), settings.documentation.daemonInterval * 1000);
+
+    // scanDir();
+}
+
+/**
+ * Recompiles the documentation page
+ */
+function recompileDocs()
+{
+    debug("Docs", "Recompiling docs...");
+
+    try
+    {
+        let filesToInject = [
+            `${settings.documentation.rawDirPath}index.js`,
+            `${settings.documentation.rawDirPath}index.css`,
+            `${settings.documentation.rawDirPath}index.html`,
+            `${settings.documentation.rawDirPath}errorPage.css`,
+            `${settings.documentation.rawDirPath}errorPage.js`
+        ];
+
+        let injectedFileNames = [
+            `${settings.documentation.compiledPath}index_injected.js`,
+            `${settings.documentation.compiledPath}index_injected.css`,
+            `${settings.documentation.compiledPath}documentation.html`,
+            `${settings.documentation.compiledPath}errorPage_injected.css`,
+            `${settings.documentation.compiledPath}errorPage_injected.js`
+        ];
+
+        let promises = [];
+        
+        process.injectionCounter = 0;
+        process.injectionTimestamp = new Date().getTime();
+
+        filesToInject.forEach((fti, i) => {
+            promises.push(new Promise((resolve, reject) => {
+                scl.unused(reject);
+                inject(fti).then((injected, injectionsNum) => {
+                    if(!scl.isEmpty(injectionsNum) && !isNaN(parseInt(injectionsNum)))
+                        process.injectionCounter += parseInt(injectionsNum);
+
+                    process.brCompErrOnce = false;
+
+                    if(settings.httpServer.encodings.gzip)
+                        saveEncoded("gzip", injectedFileNames[i], injected).catch(err => scl.unused(err));
+                    if(settings.httpServer.encodings.deflate)
+                        saveEncoded("deflate", injectedFileNames[i], injected).catch(err => scl.unused(err));
+                    if(settings.httpServer.encodings.brotli)
+                    {
+                        saveEncoded("brotli", injectedFileNames[i], injected).catch(err => {
+                            scl.unused(err);
+
+                            if(!process.brCompErrOnce)
+                            {
+                                process.brCompErrOnce = true;
+                                injectError(`Brotli compression is only supported since Node.js version 11.7.0 - current Node.js version is ${semver.clean(process.version)}`, false);
+                            }
+                        });
+                    }
+
+                    fs.writeFile(injectedFileNames[i], injected, err => {
+                        if(err)
+                            injectError(err);
+
+                        return resolve();
+                    });
+                });
+            }));
+        });
+
+        Promise.all(promises).then(() => {
+            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`);
+        }).catch(err => {
+            console.log(`Injection error: ${err}`);
+        });
+    }
+    catch(err)
+    {
+        injectError(err);
+    }
+}
+
+/**
+ * Asynchronously encodes a string and saves it encoded with the selected encoding
+ * @param {("gzip"|"deflate"|"brotli")} encoding The encoding method
+ * @param {String} filePath The path to a file to save the encoded string to - respective file extensions will automatically be added
+ * @param {String} content The string to encode
+ * @returns {Promise<null|String>} Returns a Promise. Resolve contains no parameters, reject contains error message as a string
+ */
+function saveEncoded(encoding, filePath, content)
+{
+    return new Promise((resolve, reject) => {
+        switch(encoding)
+        {
+            case "gzip":
+                zlib.gzip(content, (err, res) => {
+                    if(!err)
+                    {
+                        fs.writeFile(`${filePath}.gz`, res, err => {
+                            if(!err)
+                                return resolve();
+                            else return reject(err);
+                        });
+                    }
+                    else return reject(err);
+                });
+            break;
+            case "deflate":
+                zlib.deflate(content, (err, res) => {
+                    if(!err)
+                    {
+                        fs.writeFile(`${filePath}.zz`, res, err => {
+                            if(!err)
+                                return resolve();
+                            else return reject(err);
+                        });
+                    }
+                    else return reject(err);
+                });
+            break;
+            case "brotli":
+                if(!semver.lt(process.version, "v11.7.0")) // Brotli was added in Node v11.7.0
+                {
+                    zlib.brotliCompress(content, (err, res) => {
+                        if(!err)
+                        {
+                            fs.writeFile(`${filePath}.br`, res, err => {
+                                if(!err)
+                                    return resolve();
+                                else return reject(err);
+                            });
+                        }
+                        else return reject(err);
+                    });
+                }
+                else return reject(`Brotli compression is only supported since Node.js version "v11.7.0" - current Node.js version is "${process.version}"`);
+            break;
+            default:
+                return reject(`Encoding method "${encoding}" not found - valid methods are: "gzip", "deflate", "brotli"`);
+        }
+    });
+}
+
+/**
+ * Logs an injection error to the console
+ * @param {String} err The error message
+ * @param {Boolean} [exit=true] Whether or not to exit the process with code 1 - default: true
+ */
+function injectError(err, exit = true)
+{
+    console.log(`\n${scl.colors.fg.red}Error while injecting values into docs: ${err}${scl.colors.rst}\n`);
+    analytics({
+        type: "Error",
+        data: {
+            errorMessage: `Error while injecting into documentation: ${err}`,
+            ipAddress: `N/A`,
+            urlPath: [],
+            urlParameters: {}
+        }
+    })
+    if(exit)
+        process.exit(1);
+}
+
+/**
+ * Injects all constants, external files and values into the passed file
+ * @param {String} filePath Path to the file to inject things into
+ * @returns {Promise<String, Number>} Returns the finished file content as passed argument in a promise
+ */
+function inject(filePath)
+{
+    return new Promise((resolve, reject) => {
+        fs.readFile(filePath, (err, file) => {
+            if(err)
+                return reject(err);
+
+            try
+            {
+                file = file.toString();
+
+                //#SECTION INSERTs
+                const contributors = JSON.stringify(packageJSON.contributors);
+                const jokeCount = parseJokes.jokeCount;
+
+                const injections = {
+                    "%#INSERT:VERSION#%":               settings.info.version,
+                    "%#INSERT:NAME#%":                  settings.info.name.toString(),
+                    "%#INSERT:DESC#%":                  settings.info.desc.toString(),
+                    "%#INSERT:AUTHORWEBSITEURL#%":      settings.info.author.website.toString(),
+                    "%#INSERT:AUTHORGITHUBURL#%":       settings.info.author.github.toString(),
+                    "%#INSERT:CONTRIBUTORS#%":          (!scl.isEmpty(contributors) ? contributors : "{}"),
+                    "%#INSERT:CONTRIBUTORGUIDEURL#%":   settings.info.contribGuideUrl.toString(),
+                    "%#INSERT:PROJGITHUBURL#%":         settings.info.projGitHub.toString(),
+                    "%#INSERT:JOKESUBMISSIONURL#%":     settings.jokes.jokeSubmissionURL.toString(),
+                    "%#INSERT:CATEGORYARRAY#%":         JSON.stringify([settings.jokes.possible.anyCategoryName, ...settings.jokes.possible.categories]),
+                    "%#INSERT:FLAGSARRAY#%":            JSON.stringify(settings.jokes.possible.flags),
+                    "%#INSERT:FILEFORMATARRAY#%":       JSON.stringify(settings.jokes.possible.formats.map(itm => itm.toUpperCase())),
+                    "%#INSERT:TOTALJOKES#%":            (!scl.isEmpty(jokeCount) ? jokeCount.toString() : 0),
+                    "%#INSERT:TOTALJOKESZEROINDEXED#%": (!scl.isEmpty(jokeCount) ? (jokeCount - 1).toString() : 0),
+                    "%#INSERT:PRIVACYPOLICYURL#%":      settings.info.privacyPolicyUrl.toString(),
+                    "%#INSERT:DOCSURL#%":               (!scl.isEmpty(settings.info.docsURL) ? settings.info.docsURL : "(Error: Documentation URL not defined)"),
+                    "%#INSERT:RATELIMITCOUNT#%":        settings.httpServer.rateLimiting.toString(),
+                    "%#INSERT:FORMATVERSION#%":         settings.jokes.jokesFormatVersion.toString(),
+                    "%#INSERT:MAXPAYLOADSIZE#%":        settings.httpServer.maxPayloadSize.toString(),
+                    "%#INSERT:MAXURLLENGTH#%":          settings.httpServer.maxUrlLength.toString(),
+                    "%#INSERT:JOKELANGCOUNT#%":         languages.jokeLangs().length.toString(),
+                    "%#INSERT:SYSLANGCOUNT#%":          languages.systemLangs().length.toString(),
+                    "%#INSERT:MAXJOKEAMOUNT#%":         settings.jokes.maxAmount.toString(),
+                    "%#INSERT:JOKEENCODEAMOUNT#%":      settings.jokes.encodeAmount.toString(),
+                    "%#INSERT:SUBMISSIONRATELIMIT#%":   settings.jokes.submissions.rateLimiting.toString(),
+                    "%#INSERT:CATEGORYALIASES#%":       JSON.stringify(settings.jokes.possible.categoryAliases),
+                    "%#INSERT:LASTMODIFIEDISO#%":       new Date().toISOString().trim(),
+                };
+
+                const checkMatch = (key, regex) => {
+                    allMatches += ((file.toString().match(regex) || []).length || 0);
+                    let injection = sanitize(injections[key]);
+                    file = file.replace(regex, !scl.isEmpty(injection) ? injection : "Error");
+                };
+
+                let allMatches = 0;
+                Object.keys(injections).forEach(key => {
+                    checkMatch(key, new RegExp(`<${key}>`, "gm"));      // style: <%#INSERT:XY#%>
+                    checkMatch(key, new RegExp(`<!--${key}-->`, "gm")); // style: <!--%#INSERT:XY#%-->
+                });
+
+                if(isNaN(parseInt(allMatches)))
+                    allMatches = 0;
+                
+                process.injectionCounter += allMatches;
+                return resolve(file.toString());
+            }
+            catch(err)
+            {
+                return reject(err);
+            }
+        });
+    });
+}
+
+/**
+ * Sanitizes a string to prevent XSS
+ * @param {String} str 
+ * @returns {String}
+ */
+function sanitize(str)
+{
+    return xss(str);
+}
+
+/**
+ * Removes all line breaks and tab stops from an input string and returns it
+ * @param {String} input 
+ * @returns {String}
+ */
+function minify(input)
+{
+    return input.toString().replace(/(\n|\r\n|\t)/gm, "");
+}
+
+
+module.exports = { init, recompileDocs, minify, sanitize };

+ 94 - 0
src/env.js

@@ -0,0 +1,94 @@
+const dotenv = require("dotenv");
+
+const { colors } = require("svcorelib");
+
+const col = colors.fg;
+
+/** @typedef {import("svcorelib").JSONCompatible} JSONCompatible*/
+/** @typedef {import("./types/env").Env} Env */
+/** @typedef {import("./types/env").EnvDependentProp} EnvDependentProp */
+
+
+/** All environment-dependent settings */
+const envSettings = Object.freeze({
+    prod: {
+        name: "JokeAPI",
+        httpPort: 8076,
+        baseUrl: "https://v2.jokeapi.dev",
+    },
+    stage: {
+        name: "JokeAPI_ST",
+        httpPort: 8075,
+        baseUrl: "https://stage.jokeapi.dev",
+    },
+});
+
+let initialized = false;
+
+
+/**
+ * Initializes the deployment environment module
+ */
+function init()
+{
+    if(initialized)
+        return;
+
+    dotenv.config();
+    initialized = true;
+}
+
+/**
+ * Normalizes the deployment environment passed as the env var `NODE_ENV` and returns it
+ * @param {boolean} [colored=false] Set to `true` to color in the predefined env colors
+ * @returns {Env}
+ */
+function getEnv(colored = false)
+{
+    if(!initialized)
+        init();
+
+    if(!process.env)
+        throw new Error("no process environment found, please make sure a NODE_ENV variable is defined");
+
+    const nodeEnv = process.env.NODE_ENV ? process.env.NODE_ENV.toLowerCase() : null;
+
+
+    /** @type {Env} */
+    let env = "stage";
+
+    switch(nodeEnv)
+    {
+    case "prod":
+    case "production":
+        env = "prod";
+        break;
+    }
+
+    const envCol = env === "prod" ? col.green : col.cyan;
+
+    return colored === true ? `${envCol}${env}${col.rst}` : env;
+}
+
+/**
+ * 
+ * @param {EnvDependentProp} prop
+ * @returns {JSONCompatible}
+ */
+function getProp(prop)
+{
+    const deplEnv = getEnv();
+
+    try
+    {
+        return envSettings[deplEnv][prop];
+    }
+    catch(err)
+    {
+        console.error(`Error while resolving environment-dependent settings property '${prop}' in current env '${deplEnv}':\n${err instanceof Error ? err.stack : err}`);
+        process.exit(1);
+    }
+}
+
+
+module.exports = { init, getEnv, getProp };

+ 171 - 0
src/fileFormatConverter.js

@@ -0,0 +1,171 @@
+// this module converts JSON data into XML or YAML
+
+const jsl = require("svjsl");
+const jsonToYaml = require("json-to-pretty-yaml");
+const jsonToXml = require("js2xmlparser");
+
+const languages = require("./languages");
+const tr = require("./translate");
+const systemLangs = tr.systemLangs;
+
+const settings = require("../settings");
+
+/**
+ * Converts a JSON object to a string representation of a XML, YAML, plain text or JSON (as fallback) object - based on a passed format string
+ * @param {("xml"|"yaml"|"json"|"txt")} format Can be "xml", "yaml" or "txt", everything else will default to JSON
+ * @param {Object} jsonInput
+ * @param {String} [lang] Needed for converting to "txt"
+ * @returns {String} String representation of the converted object
+ */
+const auto = (format, jsonInput, lang) => {
+    format = format.toLowerCase();
+    switch(format)
+    {
+        case "yaml":
+            return toYAML(jsonInput);
+        case "xml":
+            return toXML(jsonInput);
+        case "txt":
+            return toTXT(jsonInput, lang);
+        case "json":
+        default:
+            return JSON.stringify(jsonInput, null, 4);
+    }
+};
+
+const toYAML = jsonInput => {
+    if(jsl.isEmpty(jsonInput))
+        return jsonToYaml.stringify({});
+    return jsonToYaml.stringify(jsonInput);
+};
+
+const toXML = jsonInput => {
+    if(jsl.isEmpty(jsonInput))
+        return jsonToXml.parse("data", {});
+    return jsonToXml.parse("data", jsonInput);
+};
+
+/**
+ * Converts a JSON object to plain text, according to the set conversion mapping
+ * @param {Object} jsonInput 
+ * @param {String} lang 
+ */
+const toTXT = (jsonInput, lang) => {
+    let returnText = tr(lang, "noConversionMapping", Object.keys(jsonInput).join(", "), "ERR_NO_CONV_MAPPING @ FFCONV");
+
+    if(!jsonInput)
+        returnText = tr(lang, "cantConvertToPlainText", "ERR_NO_JSON_INPUT @ FFCONV");
+
+    if(jsonInput)
+    {
+        if(jsonInput.error === true)
+        {
+            if(jsonInput.internalError)
+                returnText = tr(lang, "conversionInternalError", (jsonInput.code || 100), jsonInput.message, jsonInput.causedBy.join("\n- "), (jsonInput.additionalInfo ? jsonInput.additionalInfo : "X"));
+            else
+                returnText = tr(lang, "conversionGeneralError", (jsonInput.code || 100), jsonInput.message, jsonInput.causedBy.join("\n- "), (jsonInput.additionalInfo ? jsonInput.additionalInfo : "X"));
+        }
+        else
+        {
+            let categoryAliases = [];
+
+            if((jsonInput.joke || (jsonInput.jokes && Array.isArray(jsonInput.jokes))) || (jsonInput.setup && jsonInput.delivery)) // endpoint: /joke
+            {
+                if(jsonInput.type == "single")
+                    returnText = jsonInput.joke;
+                else if(jsonInput.type == "twopart")
+                    returnText = `${jsonInput.setup}\n\n${jsonInput.delivery}`;
+                else if(jsonInput.type === undefined) // amount >= 2
+                {
+                    returnText = "";
+                    jsonInput.jokes.forEach((joke, i) => {
+                        if(i != 0)
+                            returnText += "\n\n----------------------------------------------\n\n";
+
+                        if(joke.type == "single")
+                            returnText += joke.joke;
+                        else if(joke.type == "twopart")
+                            returnText += `${joke.setup}\n\n${joke.delivery}`;
+                    });
+                }
+            }
+
+            else if(jsonInput.categories) // endpoint: /categories
+            {
+                jsonInput.categoryAliases.forEach(alias => {
+                    categoryAliases.push(`- ${alias.alias} -> ${alias.resolved}`);
+                });
+                returnText = tr(lang, "availableCategories", jsonInput.categories.map(c => `- ${c}`).join("\n"), categoryAliases.join("\n"));
+            }
+
+            else if(jsonInput.flags) // endpoint: /flags
+                returnText = tr(lang, "availableFlags", jsonInput.flags.join('", "'));
+
+            else if(jsonInput.ping) // endpoint: /ping
+                returnText = `${jsonInput.ping}\n${tr(lang, "timestamp", jsonInput.timestamp)}`;
+
+            else if(jsonInput.code) // endpoint: /langcode
+                returnText = `${jsonInput.error ? tr(lang, "genericError", jsonInput.message) : tr(lang, "languageCode", jsonInput.code)}`;
+
+            else if(jsonInput.defaultLanguage) // endpoint: /languages
+            {
+                let suppLangs = [];
+                languages.jokeLangs().forEach(lang => {
+                    suppLangs.push(`${lang.name} [${lang.code}]`);
+                });
+
+                let sysLangs = systemLangs().map(lc => `${languages.codeToLanguage(lc)} [${lc}]`);
+
+                let possLangs = [];
+
+                jsonInput.possibleLanguages.forEach(pl => {
+                    possLangs.push(`${pl.name} [${pl.code}]`);
+                });
+
+                returnText = tr(lang, "languagesEndpoint", languages.codeToLanguage(jsonInput.defaultLanguage), jsonInput.defaultLanguage, languages.jokeLangs().length, suppLangs.sort().join(", "), sysLangs.length, sysLangs.sort().join(", "), possLangs.sort().join("\n"));
+            }
+
+            else if(jsonInput.version) // endpoint: /info
+            {
+                let suppLangs = [];
+                languages.jokeLangs().forEach(lang => {
+                    suppLangs.push(`${lang.name} [${lang.code}]`);
+                });
+
+                let sysLangs = systemLangs().map(lc => `${languages.codeToLanguage(lc)} [${lc}]`);
+
+                let idRanges = [];
+                Object.keys(jsonInput.jokes.idRange).forEach(lc => {
+                    let lcIr = jsonInput.jokes.idRange[lc];
+                    idRanges.push(`${languages.codeToLanguage(lc)} [${lc}]: ${lcIr[0]}-${lcIr[1]}`);
+                });
+
+                let safeJokesAmounts = [];
+                jsonInput.jokes.safeJokes.forEach(safeJokesObj => {
+                    safeJokesAmounts.push(`${languages.codeToLanguage(safeJokesObj.lang)} [${safeJokesObj.lang}]: ${safeJokesObj.count}`);
+                });
+
+                returnText = tr(lang, "infoEndpoint",
+                                    settings.info.name, jsonInput.version, jsonInput.jokes.totalCount, jsonInput.jokes.categories.join(`", "`), jsonInput.jokes.flags.join('", "'),
+                                    jsonInput.formats.join('", "'), jsonInput.jokes.types.join('", "'), jsonInput.jokes.submissionURL, idRanges.join("\n"), languages.jokeLangs().length,
+                                    suppLangs.sort().join(", "), sysLangs.length, sysLangs.sort().join(", "), safeJokesAmounts.join("\n"), jsonInput.info
+                                );
+            }
+
+            else if(jsonInput.formats) // endpoint: /formats
+                returnText = tr(lang, "availableFormats", `"${jsonInput.formats.join('", "')}"`);
+
+            else if(Array.isArray(jsonInput) && jsonInput[0].usage && jsonInput[0].usage.method) // endpoint: /endpoints
+            {
+                returnText = `${tr(lang, "endpointsWord")}:\n\n\n`;
+                jsonInput.forEach(ep => {
+                    returnText += `${tr(lang, "endpointDetails", ep.name, ep.description, ep.usage.method, ep.usage.url, (ep.usage.supportedParams.length > 0 ? `"${ep.usage.supportedParams.join('", "')}"` : "X"))}\n\n`;
+                });
+            }
+        }
+    }
+
+    return returnText;
+};
+
+module.exports = { auto, toYAML, toXML, toTXT };

+ 845 - 0
src/httpServer.js

@@ -0,0 +1,845 @@
+// 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 };

+ 215 - 0
src/jokeSubmission.js

@@ -0,0 +1,215 @@
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+const http = require("http");
+
+var httpServer = require("./httpServer"); // module loading order is a bit fucked, so this module has to be loaded multiple times
+const parseJokes = require("./parseJokes");
+const logRequest = require("./logRequest");
+const convertFileFormat = require("./fileFormatConverter");
+const analytics = require("./analytics");
+const parseURL = require("./parseURL");
+const meter = require("./meter");
+const tr = require("./translate");
+
+const settings = require("../settings");
+const fileFormatConverter = require("./fileFormatConverter");
+
+jsl.unused(http, analytics, tr);
+
+/** @typedef {parseJokes.SingleJoke|parseJokes.TwopartJoke} JokeSubmission */
+/** @typedef {import("./types/jokes").Joke} Joke */
+
+
+/**
+ * To be called when a joke is submitted
+ * @param {http.ServerResponse} res
+ * @param {String} data
+ * @param {String} fileFormat
+ * @param {String} ip
+ * @param {(analytics.AnalyticsDocsRequest|analytics.AnalyticsSuccessfulRequest|analytics.AnalyticsRateLimited|analytics.AnalyticsError|analytics.AnalyticsSubmission)} analyticsObject
+ * @param {Boolean} dryRun Set to true to not add the joke to the joke file after validating it
+ */
+function jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun)
+{
+    try
+    {
+        if(typeof dryRun != "boolean")
+            dryRun = false;
+
+        if(typeof httpServer == "object" && Object.keys(httpServer).length <= 0)
+            httpServer = require("./httpServer");
+            
+        let submittedJoke = JSON.parse(data);
+
+        let langCode = (submittedJoke.lang || settings.languages.defaultLanguage).toString().toLowerCase();
+
+        if(jsl.isEmpty(submittedJoke))
+            return httpServer.respondWithError(res, 105, 400, fileFormat, tr(langCode, "requestBodyIsInvalid"), langCode);
+            
+        let invalidChars = data.match(settings.jokes.submissions.invalidCharRegex);
+        let invalidCharsStr = invalidChars ? invalidChars.map(ch => `0x${ch.charCodeAt(0).toString(16)}`).join(", ") : null;
+        if(invalidCharsStr && invalidChars.length > 0)
+            return httpServer.respondWithError(res, 109, 400, fileFormat, tr(langCode, "invalidChars", invalidCharsStr), langCode);
+        
+        if(submittedJoke.formatVersion == parseJokes.jokeFormatVersion && submittedJoke.formatVersion == settings.jokes.jokesFormatVersion)
+        {
+            // format version is correct, validate joke now
+            let validationResult = parseJokes.validateSingle(submittedJoke, langCode);
+
+            if(Array.isArray(validationResult))
+                return httpServer.respondWithError(res, 105, 400, fileFormat, tr(langCode, "submittedJokeFormatInvalid", validationResult.join("\n")), langCode);
+            else if(validationResult === true)
+            {
+                // joke is valid, find file name and then write to file
+
+                let sanitizedIP = ip.replace(settings.httpServer.ipSanitization.regex, settings.httpServer.ipSanitization.replaceChar).substring(0, 8);
+                let curUnix = new Date().getTime();
+                let fileName = `${settings.jokes.jokeSubmissionPath}${langCode}/submission_${sanitizedIP}_0_${curUnix}.json`;
+
+                let iter = 0;
+                let findNextNum = currentNum => {
+                    iter++;
+                    if(iter >= settings.httpServer.rateLimiting)
+                    {
+                        logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
+                        return httpServer.respondWithError(res, 101, 429, fileFormat, tr(langCode, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame));
+                    }
+
+                    if(fs.existsSync(`${settings.jokes.jokeSubmissionPath}submission_${sanitizedIP}_${currentNum}_${curUnix}.json`))
+                        return findNextNum(currentNum + 1);
+                    else return currentNum;
+                };
+
+                fs.ensureDirSync(`${settings.jokes.jokeSubmissionPath}${langCode}`);
+
+                if(fs.existsSync(`${settings.jokes.jokeSubmissionPath}${fileName}`))
+                    fileName = `${settings.jokes.jokeSubmissionPath}${langCode}/submission_${sanitizedIP}_${findNextNum()}_${curUnix}.json`;
+
+                try
+                {
+                    // file name was found, write to file now:
+                    if(dryRun)
+                    {
+                        let respObj = {
+                            error: false,
+                            message: tr(langCode, "dryRunSuccessful", parseJokes.jokeFormatVersion, submittedJoke.formatVersion),
+                            timestamp: new Date().getTime()
+                        };
+
+                        return httpServer.pipeString(res, fileFormatConverter.auto(fileFormat, respObj, langCode), parseURL.getMimeTypeFromFileFormatString(fileFormat), 201);
+                    }
+
+                    return writeJokeToFile(res, fileName, submittedJoke, fileFormat, ip, analyticsObject, langCode);
+                }
+                catch(err)
+                {
+                    return httpServer.respondWithError(res, 100, 500, fileFormat, tr(langCode, "errWhileSavingSubmission", err), langCode);
+                }
+            }
+        }
+        else
+        {
+            return httpServer.respondWithError(res, 105, 400, fileFormat, tr(langCode, "wrongFormatVersion", parseJokes.jokeFormatVersion, submittedJoke.formatVersion), langCode);
+        }
+    }
+    catch(err)
+    {
+        return httpServer.respondWithError(res, 105, 400, fileFormat, tr(settings.languages.defaultLanguage, "invalidJSON", err), settings.languages.defaultLanguage);
+    }
+}
+
+/**
+ * Writes a joke to a json file
+ * @param {http.ServerResponse} res
+ * @param {String} filePath
+ * @param {JokeSubmission} submittedJoke
+ * @param {String} fileFormat
+ * @param {String} ip
+ * @param {(analytics.AnalyticsDocsRequest|analytics.AnalyticsSuccessfulRequest|analytics.AnalyticsRateLimited|analytics.AnalyticsError|analytics.AnalyticsSubmission)} analyticsObject
+ * @param {String} [langCode]
+ */
+function writeJokeToFile(res, filePath, submittedJoke, fileFormat, ip, analyticsObject, langCode)
+{
+    if(typeof httpServer == "object" && Object.keys(httpServer).length <= 0)
+        httpServer = require("./httpServer");
+
+    let reformattedJoke = reformatJoke(submittedJoke);
+
+    fs.writeFile(filePath, JSON.stringify(reformattedJoke, null, 4), err => {
+        if(!err)
+        {
+            // successfully wrote to file
+            let responseObj = {
+                "error": false,
+                "message": tr(langCode, "submissionSaved"),
+                "submission": reformattedJoke,
+                "timestamp": new Date().getTime()
+            };
+
+            meter.update("submission", 1);
+
+            let submissionObject = analyticsObject;
+            submissionObject.submission = reformattedJoke;
+            logRequest("submission", ip, submissionObject);
+
+            return httpServer.pipeString(res, convertFileFormat.auto(fileFormat, responseObj, langCode), parseURL.getMimeTypeFromFileFormatString(fileFormat), 201);
+        }
+        // error while writing to file
+        else return httpServer.respondWithError(res, 100, 500, fileFormat, tr(langCode, "errWhileSavingSubmission", err), langCode);
+    });
+}
+
+/**
+ * Coarse filter that ensures that a joke is formatted as expected.  
+ * This doesn't do any validation and omits missing properties!
+ * @param {Joke|JokeSubmission} joke
+ * @returns {Joke|JokeSubmission} Returns the reformatted joke
+ */
+function reformatJoke(joke)
+{
+    let retJoke = {};
+
+    if(joke.formatVersion)
+        retJoke.formatVersion = joke.formatVersion;
+
+    retJoke = {
+        ...retJoke,
+        category: typeof joke.category === "string" ? parseJokes.resolveCategoryAlias(joke.category) : joke.category,
+        type: joke.type
+    };
+
+    if(joke.type == "single")
+    {
+        retJoke.joke = joke.joke;
+    }
+    else if(joke.type == "twopart")
+    {
+        retJoke.setup = joke.setup;
+        retJoke.delivery = joke.delivery;
+    }
+
+    retJoke.flags = {
+        nsfw: joke.flags.nsfw,
+        religious: joke.flags.religious,
+        political: joke.flags.political,
+        racist: joke.flags.racist,
+        sexist: joke.flags.sexist,
+        explicit: joke.flags.explicit,
+    };
+
+    if(joke.lang)
+        retJoke.lang = joke.lang;
+
+    if(typeof retJoke.lang === "string")
+        retJoke.lang = retJoke.lang.toLowerCase();
+
+    retJoke.safe = joke.safe || false;
+        
+    if(joke.id)
+        retJoke.id = joke.id;
+
+    return retJoke;
+}
+
+module.exports = jokeSubmission;
+module.exports.reformatJoke = reformatJoke;

+ 180 - 0
src/languages.js

@@ -0,0 +1,180 @@
+const fs = require("fs-extra");
+const jsl = require("svjsl");
+const Fuse = require("fuse.js");
+
+const debug = require("./verboseLogging");
+const tr = require("./translate");
+
+const settings = require("../settings");
+
+var langs;
+
+/**
+ * Initializes the language module
+ */
+function init()
+{
+    debug("Languages", `Initializing - loading languages from "${settings.languages.langFilePath}"`);
+    return new Promise((resolve, reject) => {
+        fs.readFile(settings.languages.langFilePath, (err, data) => {
+            if(err)
+                return reject(err);
+            else
+            {
+                let languages = JSON.parse(data.toString());
+                debug("Languages", `Found ${Object.keys(languages).length} languages`);
+                langs = languages;
+                return resolve(languages);
+            }
+        });
+    });
+}
+
+/**
+ * Checks whether or not a provided language code is ISO 639-1 or ISO 639-2 compatible
+ * @param {String} langCode Two-character language code
+ * @param {String} [trLang] For translating the error messages
+ * @returns {Boolean|String} Returns `true` if code exists, string with error message if not
+ */
+function isValidLang(langCode, trLang)
+{
+    // if trLang not provided or it was provided but is invalid, reset to default lang
+    if(trLang != "string" || (typeof trLang == "string" && isValidLang(trLang) !== true))
+        trLang = settings.languages.defaultLanguage;
+
+    if(langs == undefined)
+        return tr(trLang, "langModuleInitError");
+
+    if(typeof langCode !== "string" || langCode.length !== 2)
+        return tr(trLang, "langCodeInvalidValue");
+
+    let requested = langs[langCode.toLowerCase()];
+
+    if(typeof requested === "string")
+        return true;
+    else
+        return tr(trLang, "langCodeDoesntExist");
+}
+
+/**
+ * Converts a language name (fuzzy) into an ISO 639-1 or ISO 639-2 compatible lang code
+ * @param {String} language
+ * @returns {Boolean|String} Returns `false` if no matching language code was found, else returns string with language code
+ */
+function languageToCode(language)
+{
+    if(langs == undefined)
+        throw new Error("INTERNAL_ERROR: Language module was not correctly initialized (yet)");
+
+    if(typeof language !== "string" || language.length < 1)
+        throw new TypeError("Language is not a string or not two characters in length");
+
+    let searchObj = [];
+
+    Object.keys(langs).forEach(key => {
+        searchObj.push({
+            code: key,
+            lang: langs[key].toLowerCase()
+        });
+    });
+
+    let fuzzy = new Fuse(searchObj, {
+        includeScore: true,
+        keys: ["code", "lang"],
+        threshold: 0.4
+    });
+
+    let result = fuzzy.search(language)[0];
+
+    if(result)
+        return result.item.code;
+    else
+        return false;
+}
+
+/**
+ * Converts an ISO 639-1 or ISO 639-2 compatible lang code into a language name
+ * @param {String} code
+ * @returns {Boolean|String} Returns `false` if no matching language was found, else returns string with language name
+ */
+function codeToLanguage(code)
+{
+    try
+    {
+        let jsonObj = JSON.parse(fs.readFileSync(settings.languages.langFilePath).toString());
+
+        return jsonObj[code] || false;
+    }
+    catch(err)
+    {
+        jsl.unused(err);
+        return false;
+    }
+}
+
+/**
+ * @typedef {Object} SupLangObj
+ * @prop {String} code
+ * @prop {String} name
+ */
+
+/**
+ * Returns a list of languages that jokes are available from
+ * @returns {Array<SupLangObj>}
+ */
+function jokeLangs()
+{
+    let retLangs = [];
+
+    fs.readdirSync(settings.jokes.jokesFolderPath).forEach(f => {
+        if(f == settings.jokes.jokesTemplateFile)
+            return;
+
+        let langCode = f.split("-")[1].substr(0, 2);
+
+        retLangs.push({
+            code: langCode,
+            name: codeToLanguage(langCode)
+        });
+    });
+
+    return retLangs;
+}
+
+/**
+ * Returns a list of languages that error messages and maybe other stuff are available as
+ * @returns {Array<SupLangObj>}
+ */
+function systemLangs()
+{
+    return tr.systemLangs();
+}
+
+/**
+ * Returns all possible language codes
+ * @returns {Array<String>}
+ */
+function getPossibleCodes()
+{
+    return Object.keys(langs);
+}
+
+/**
+ * Returns all possible languages, mapped as an object where keys are codes and values are language names
+ * @returns {Object}
+ */
+function getPossibleLanguages()
+{
+    return langs;
+}
+
+module.exports = {
+    init,
+    isValidLang,
+    languageToCode,
+    codeToLanguage,
+    jokeLangs,
+    systemLangs,
+    getPossibleCodes,
+    getPossibleLanguages
+};

+ 162 - 0
src/lists.js

@@ -0,0 +1,162 @@
+// this module initializes the blacklist, whitelist and console blacklist
+
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+const settings = require("../settings");
+const debug = require("./verboseLogging");
+const logger = require("./logger");
+const resolveIP = require("./resolveIP");
+
+
+/**
+ * Initializes all lists (blacklist, whitelist and console blacklist)
+ * @returns {Promise}
+ */
+function init()
+{
+    return new Promise((resolve, reject) => {
+        //#SECTION read list files
+        debug("Lists", "Reading blacklist...");
+        fs.readFile(settings.lists.blacklistPath, (err1, blacklist) => {
+            if(!jsl.isEmpty(err1) && !err1.toString().includes("ENOENT"))
+                return reject(err1);
+            else if(!jsl.isEmpty(err1) && err1.toString().includes("ENOENT"))
+            {
+                fs.writeFileSync(settings.lists.blacklistPath, "[\n\t\n]");
+                debug("Lists", `${jsl.colors.fg.red}No blacklist file found! Created empty list.${jsl.colors.rst}`);
+                blacklist = "[\n\t\n]";
+            }
+
+            blacklist = blacklist.toString();
+            debug("Lists", "Reading whitelist...");
+            fs.readFile(settings.lists.whitelistPath, (err2, whitelist) => {
+                if(!jsl.isEmpty(err2) && !err2.toString().includes("ENOENT"))
+                    return reject(err2);
+                else if(!jsl.isEmpty(err2) && err2.toString().includes("ENOENT"))
+                {
+                    debug("Lists", `${jsl.colors.fg.red}No whitelist file found! Defaulting to empty list.${jsl.colors.rst}`);
+                    whitelist = "[\n\t\n]";
+                }
+
+                whitelist = whitelist.toString();
+                debug("Lists", "Reading console blacklist...");
+                fs.readFile(settings.lists.consoleBlacklistPath, (err3, consoleBlacklist) => {
+                    if(!jsl.isEmpty(err3) && !err3.toString().includes("ENOENT"))
+                        return reject(err3);
+                    else if(!jsl.isEmpty(err3) && err3.toString().includes("ENOENT"))
+                        consoleBlacklist = "[\n\t\n]";
+
+                    consoleBlacklist = consoleBlacklist.toString();
+                    
+                    //#SECTION put lists in the process object
+                    try
+                    {
+                        if(jsl.isEmpty(process.jokeapi))
+                            process.jokeapi = {};
+                        if(jsl.isEmpty(process.jokeapi.lists))
+                            process.jokeapi.lists = {};
+                        
+                        process.jokeapi.lists = {
+                            blacklist: JSON.parse(blacklist),
+                            whitelist: JSON.parse(whitelist),
+                            consoleBlacklist: JSON.parse(consoleBlacklist)
+                        };
+
+                        if(!jsl.isEmpty(process.jokeapi.lists))
+                        {
+                            debug("Lists", "Finished reading and initializing all lists");
+                            return resolve(process.jokeapi.lists);
+                        }
+                        return reject(`Unexpected error: process.jokeapi.lists is empty (${typeof process.jokeapi.lists})`);
+                    }
+                    catch(err)
+                    {
+                        return reject(err);
+                    }
+                });
+            });
+        });
+    });
+}
+
+/**
+ * Checks whether a provided IP address is in the blacklist
+ * @param {String} ip
+ * @returns {Bool} true if blacklisted, false if not
+ */
+function isBlacklisted(ip)
+{
+    if(jsl.isEmpty(process.jokeapi) || jsl.isEmpty(process.jokeapi.lists) || !(process.jokeapi.lists.blacklist instanceof Array))
+    {
+        logger("fatal", `Blacklist was not initialized when calling lists.isBlacklisted()`, true);
+        throw new Error(`Blacklist was not initialized`);
+    }
+
+    if(jsl.isEmpty(process.jokeapi.lists.blacklist) || process.jokeapi.lists.blacklist.length == 0)
+        return false;
+    
+    let returnVal = false;
+
+    process.jokeapi.lists.blacklist.forEach(blIP => {
+        if(!returnVal && (ip == blIP || ip == resolveIP.hashIP(blIP)))
+            returnVal = true;
+    });
+
+    if(returnVal === true)
+        debug("Lists", "Is blacklisted.");
+
+    return returnVal;
+}
+
+/**
+ * Checks whether a provided IP address is in the whitelist
+ * @param {String} ip
+ * @returns {Bool} Returns true if whitelisted, false if not
+ * @throws Throws an error if the lists module was not previously initialized
+ */
+function isWhitelisted(ip)
+{
+    let whitelisted = false;
+
+    if(jsl.isEmpty(process.jokeapi) || jsl.isEmpty(process.jokeapi.lists) || !(process.jokeapi.lists.whitelist instanceof Array))
+    {
+        logger("fatal", `Whitelist was not initialized when calling lists.isWhitelisted()`, true);
+        throw new Error(`Whitelist was not initialized`);
+    }
+
+    if(jsl.isEmpty(process.jokeapi.lists.whitelist) || process.jokeapi.lists.whitelist.length == 0)
+        return false;
+
+    process.jokeapi.lists.whitelist.forEach(wlIP => {
+        if(!whitelisted && (ip == wlIP || ip == resolveIP.hashIP(wlIP)))
+            whitelisted = true;
+    });
+    return whitelisted;
+}
+
+/**
+ * Checks whether a provided IP address is in the console blacklist
+ * @param {String} ip
+ * @returns {Bool} true if console blacklisted, false if not
+ */
+function isConsoleBlacklisted(ip)
+{
+    if(jsl.isEmpty(process.jokeapi) || jsl.isEmpty(process.jokeapi.lists) || !(process.jokeapi.lists.consoleBlacklist instanceof Array))
+    {
+        logger("fatal", `Console blacklist was not initialized when calling lists.isConsoleBlacklisted()`, true);
+        throw new Error(`Console blacklist was not initialized`);
+    }
+
+    if(jsl.isEmpty(process.jokeapi.lists.consoleBlacklist) || process.jokeapi.lists.consoleBlacklist.length == 0)
+        return false;
+    
+    let returnVal = false;
+
+    process.jokeapi.lists.consoleBlacklist.forEach(cblIP => {
+        if(!returnVal && (ip == cblIP || ip == resolveIP.hashIP(cblIP)))
+            returnVal = true;
+    });
+    return returnVal;
+}
+
+module.exports = { init, isBlacklisted, isWhitelisted, isConsoleBlacklisted };

+ 189 - 0
src/logRequest.js

@@ -0,0 +1,189 @@
+const jsl = require("svjsl");
+
+const logger = require("./logger");
+const parseJokes = require("./parseJokes");
+const analytics = require("./analytics");
+const languages = require("./languages");
+const { getEnv } = require("./env");
+
+const settings = require("../settings");
+
+
+/**
+ * @typedef {Object} AnalyticsData
+ * @prop {String} ipAddress
+ * @prop {Array<String>} urlPath
+ * @prop {Object} urlParameters
+ * @prop {Object} [submission] Only has to be used on type = "submission"
+ */
+
+/**
+ * Logs a request to the console. The `type` parameter specifies the color and additional logging level
+ * @param {("success"|"docs"|"ratelimited"|"error"|"blacklisted"|"docsrecompiled"|"submission")} type 
+ * @param {String} [additionalInfo] Provides additional information in certain log types
+ * @param {AnalyticsData} [analyticsData] Additional analytics data
+ */
+const logRequest = (type, additionalInfo, analyticsData) => {
+    let color = "";
+    let logType = null;
+    let logDisabled = false;
+    let spacerDisabled = false;
+    let logChar = settings.logging.logChar;
+
+    if(settings.debug.onlyLogErrors)
+        logDisabled = true;
+
+    switch(type)
+    {
+        case "success":
+            color = settings.colors.success;
+
+            if(!jsl.isEmpty(analyticsData))
+            {
+                analytics({
+                    type: "SuccessfulRequest",
+                    data: {
+                        ipAddress: analyticsData.ipAddress,
+                        urlPath: analyticsData.urlPath,
+                        urlParameters: analyticsData.urlParameters
+                    }
+                });
+            }
+        break;
+        case "docs":
+            color = settings.colors.docs;
+
+            if(!jsl.isEmpty(analyticsData))
+            {
+                analytics({
+                    type: "Docs",
+                    data: {
+                        ipAddress: analyticsData.ipAddress,
+                        urlPath: analyticsData.urlPath,
+                        urlParameters: analyticsData.urlParameters
+                    }
+                });
+            }
+        break;
+        case "ratelimited":
+            color = settings.colors.ratelimit;
+            // logType = "ratelimit";
+
+            if(!jsl.isEmpty(analyticsData))
+            {
+                analytics({
+                    type: "RateLimited",
+                    data: {
+                        ipAddress: analyticsData.ipAddress,
+                        urlPath: analyticsData.urlPath,
+                        urlParameters: analyticsData.urlParameters
+                    }
+                });
+            }
+        break;
+        case "error":
+            if(settings.debug.onlyLogErrors)
+                logDisabled = false;
+
+            color = settings.colors.ratelimit;
+            logType = "error";
+
+            if(!jsl.isEmpty(analyticsData))
+            {
+                analytics({
+                    type: "Error",
+                    data: {
+                        ipAddress: analyticsData.ipAddress,
+                        urlPath: analyticsData.urlPath,
+                        urlParameters: analyticsData.urlParameters,
+                        errorMessage: additionalInfo
+                    }
+                });
+            }
+        break;
+        case "docsrecompiled":
+            color = settings.colors.docsrecompiled;
+            logChar = "r ";
+        break;
+        case "submission":
+            logChar = `\n\n${jsl.colors.fg.blue}⯈ Got a submission${!jsl.isEmpty(additionalInfo) ? ` from ${jsl.colors.fg.yellow}${additionalInfo.substring(0, 8)}` : ""}${jsl.colors.rst}\n\n`;
+            spacerDisabled = true;
+
+            if(!jsl.isEmpty(analyticsData))
+            {
+                analytics({
+                    type: "JokeSubmission",
+                    data: {
+                        ipAddress: analyticsData.ipAddress,
+                        urlPath: analyticsData.urlPath,
+                        urlParameters: analyticsData.urlParameters,
+                        submission: analyticsData.submission
+                    }
+                });
+            }
+        break;
+        case "blacklisted":
+            color = settings.colors.blacklisted;
+            logChar = "*";
+            if(!settings.logging.blacklistLoggingEnabled)
+                logDisabled = true;
+
+            if(!jsl.isEmpty(analyticsData))
+            {
+                analytics({
+                    type: "Blacklisted",
+                    data: {
+                        ipAddress: analyticsData.ipAddress,
+                        urlPath: analyticsData.urlPath,
+                        urlParameters: analyticsData.urlParameters,
+                        submission: analyticsData.submission
+                    }
+                });
+            }
+        break;
+    }
+
+    if(!settings.logging.disableLogging && !logDisabled)
+        process.stdout.write(`${(process.jokeapi.reqCounter % settings.logging.spacerAfter && !spacerDisabled) == 0 ? " " : ""}${color}${logChar}${jsl.colors.rst}`);
+
+    if(logType != null)
+        logger(logType, !jsl.isEmpty(additionalInfo) ? additionalInfo : "no additional information provided", true);
+
+    if(jsl.isEmpty(process.jokeapi.reqCounter))
+        process.jokeapi.reqCounter = 0;
+    
+    if(!spacerDisabled)
+        process.jokeapi.reqCounter++;
+}
+
+/**
+ * Sends an initialization message - called when the initialization is done
+ * @param {Number} initTimestamp The timestamp of when JokeAPI was initialized
+ */
+const initMsg = (initTimestamp) => {
+    let initMs = (new Date().getTime() - initTimestamp).toFixed(0);
+
+    console.log(`\n${jsl.colors.fg.blue}[${logger.getTimestamp(" | ")}] ${jsl.colors.rst}- ${jsl.colors.fg.green}${settings.info.name} v${settings.info.version}${jsl.colors.rst} [${getEnv()}]`);
+    console.log(` ├─ Registered and validated ${jsl.colors.fg.green}${parseJokes.jokeCount}${jsl.colors.rst} jokes from ${jsl.colors.fg.green}${languages.jokeLangs().length}${jsl.colors.rst} languages`);
+    console.log(` ├─ ${jsl.colors.fg.green}${settings.jokes.possible.categories.length}${jsl.colors.rst} categories, ${jsl.colors.fg.green}${settings.jokes.possible.flags.length}${jsl.colors.rst} flags, ${jsl.colors.fg.green}${settings.jokes.possible.formats.length}${jsl.colors.rst} formats`);
+    if(analytics.connectionInfo && analytics.connectionInfo.connected)
+        console.log(` ├─ Connected to analytics database at ${jsl.colors.fg.green}${analytics.connectionInfo.info}${jsl.colors.rst}`);
+    else
+        console.log(` ├─ Analytics database ${settings.analytics.enabled ? jsl.colors.fg.red : jsl.colors.fg.yellow}not connected${settings.analytics.enabled ? "" : " (disabled)"}${jsl.colors.rst}`);
+    console.log(` ├─ ${settings.info.name} is listening at ${jsl.colors.fg.green}http://127.0.0.1:${settings.httpServer.port}${jsl.colors.rst}`);
+    console.log(` └─ Initialization took around ${jsl.colors.fg.green}${initMs}ms${initMs == 69 ? " (nice)" : ""}${jsl.colors.rst}`);
+    process.stdout.write("\n");
+    console.log(`Colors: ${jsl.colors.fg.green}Success ${jsl.colors.fg.yellow}Warning ${jsl.colors.fg.red}Error${jsl.colors.rst}`);
+    
+    if(!settings.debug.onlyLogErrors)
+    {
+        console.log(`\n  ${settings.colors.success}${settings.logging.logChar} Success ${settings.colors.docs}${settings.logging.logChar} Docs ${settings.colors.ratelimit}${settings.logging.logChar} RateLimited ${settings.colors.error}${settings.logging.logChar} Error${jsl.colors.rst}`);
+        process.stdout.write("\x1b[2m");
+        process.stdout.write("└┬───────────────────────────────────────┘\n");
+        process.stdout.write(" └─► ");
+        process.stdout.write(jsl.colors.rst);
+    }
+}
+
+module.exports = logRequest;
+module.exports.initMsg = initMsg;

+ 79 - 0
src/logger.js

@@ -0,0 +1,79 @@
+const fs = require("fs-extra");
+const jsl = require("svjsl");
+const settings = require("../settings");
+
+/**
+ * Logs something to a file
+ * @param {("error"|"ratelimit"|"fatal")} type The type of log
+ * @param {String} content The content of the log
+ * @param {Boolean} timestamp Whether or not to include a timestamp
+ */
+const logger = (type, content, timestamp) => {
+    try
+    {
+        timestamp = jsl.isEmpty(timestamp) || typeof timestamp != "boolean" ? true: timestamp;
+
+        let errorType = "";
+        let errorContent = "";
+
+        switch(type)
+        {
+            case "error":
+            case "ratelimit":
+            case "fatal":
+                errorType = type;
+                errorContent = content;
+            break;
+            default:
+                errorType = "fatal";
+                errorContent = `Error while logging - wrong type ${type} specified.\nContent of the error: ${content}`;
+            break;
+        }
+
+        if(timestamp)
+        {
+            errorContent = `[${getTimestamp()}]  ${errorContent}`;
+        }
+
+        errorContent += "\n";
+
+        let logFileName = `${settings.errors.errorLogDir}${errorType}.log`;
+
+        if(fs.existsSync(logFileName))
+            fs.appendFileSync(logFileName, errorContent);
+        else
+            fs.writeFileSync(logFileName, errorContent);
+    }
+    catch(err)
+    {
+        console.log(`\n\n${jsl.colors.fg.red}Fatal Error while logging!\n${jsl.colors.fg.yellow}${err}${jsl.colors.rst}\n`);
+        process.exit(1);
+    }
+};
+
+/**
+ * Returns a preformatted timestamp in local time
+ * @param {String} [separator] A separator to add between the date and the time - leave empty for " | "
+ * @returns {String}
+ */
+const getTimestamp = (separator) => {
+    let d = new Date();
+
+    let dt = {
+        y: d.getFullYear(),
+        m: (d.getMonth() + 1),
+        d: d.getDate(),
+        th: d.getHours(),
+        tm: d.getMinutes(),
+        ts: d.getSeconds()
+    }
+
+    // Why is there no Date.format() function in JS :(
+    return `${dt.y}/${(dt.m < 10 ? "0" : "") + dt.m}/${(dt.d < 10 ? "0" : "") + dt.d}`
+         + `${jsl.isEmpty(separator) ? " | " : separator}`
+         + `${(dt.th < 10 ? "0" : "") + dt.th}:${(dt.tm < 10 ? "0" : "") + dt.tm}:${(dt.ts < 10 ? "0" : "") + dt.ts}`;
+
+}
+
+module.exports = logger;
+module.exports.getTimestamp = getTimestamp;

+ 157 - 0
src/main.js

@@ -0,0 +1,157 @@
+// The main coordination file of JokeAPI
+// This file starts all necessary modules like the joke parser, the JokeAPI Documentation page injection and the HTTP listener, etc.
+
+"use strict";
+
+
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+const promiseAllSequential = require("promise-all-sequential");
+require("dotenv").config();
+
+const env = require("./env");
+const debug = require("./verboseLogging");
+const parseJokes = require("./parseJokes");
+const httpServer = require("./httpServer");
+const lists = require("./lists");
+const docs = require("./docs");
+const analytics = require("./analytics");
+const logRequest = require("./logRequest");
+const auth = require("./auth");
+const languages = require("./languages");
+const translate = require("./translate");
+const meter = require("./meter");
+const settings = require("../settings");
+
+const col = jsl.colors.fg;
+process.debuggerActive = jsl.inDebugger();
+const noDbg = process.debuggerActive || false;
+
+settings.init.exitSignals.forEach(sig => {
+    process.on(sig, () => softExit(0));
+});
+
+//#MARKER init all
+const initAll = () => {
+    env.init();
+
+    let initTimestamp = new Date().getTime();
+
+    console.log(`Initializing ${settings.info.name}...\n`);
+
+    process.jokeapi = {};
+    initializeDirs();
+
+    let initPromises = [];
+    let initStages = [
+        {
+            name: "Initializing languages",
+            fn: languages.init
+        },
+        {
+            name: "Initializing translations",
+            fn: translate.init
+        },
+        {
+            name: "Initializing joke parser",
+            fn: parseJokes.init
+        },
+        {
+            name: "Initializing lists",
+            fn: lists.init
+        },
+        {
+            name: "Initializing documentation",
+            fn: docs.init
+        },
+        {
+            name: "Initializing authorization module",
+            fn: auth.init
+        },
+        {
+            name: "Initializing HTTP server",
+            fn: httpServer.init
+        },
+        {
+            name: "Initializing analytics module",
+            fn: analytics.init
+        },
+        {
+            name: "Initializing pm2 meter",
+            fn: meter.init
+        }
+    ];
+
+    let pb;
+    if(!noDbg && !settings.debug.progressBarDisabled)
+        pb = new jsl.ProgressBar(initStages.length, initStages[0].name);
+
+    initStages.forEach(stage => {
+        initPromises.push(stage.fn);
+    });
+
+    debug("Init", `Sequentially initializing all ${initStages.length} modules...`);
+
+    promiseAllSequential(initPromises).then((res) => {
+        jsl.unused(res);
+
+        if(!jsl.isEmpty(pb))
+            pb.next("Done.");
+
+        debug("Init", `Done initializing all ${initStages.length} modules. Printing init message...`);
+
+        logRequest.initMsg(initTimestamp);
+    }).catch(err => {
+        initError("initializing", err);
+    });
+};
+
+
+//#MARKER other
+
+/**
+ * This function gets called when JokeAPI encounters an error while initializing.
+ * Because the initialization phase is such a delicate and important process, JokeAPI shuts down if an error is encountered.
+ * @param {String} action 
+ * @param {Error} err 
+ */
+const initError = (action, err) => {
+    let errMsg = err.stack || err || "(No error message provided)";
+    console.log(`\n\n\n${col.red}JokeAPI encountered an error while ${action}:\n${errMsg}\n\n${jsl.colors.rst}`);
+    process.exit(1);
+}
+
+/**
+ * Makes sure all directories exist and creates them if they don't
+ */
+const initializeDirs = () => {
+    try
+    {
+        settings.init.initDirs.forEach(dir => {
+            if(!fs.existsSync(dir))
+            {
+                debug("InitDirs", `Dir "${dir}" doesn't exist, creating it...`);
+                fs.mkdirSync(dir);
+            }
+        });
+    }
+    catch(err)
+    {
+        initError("initializing default directories", err);
+    }
+}
+
+/**
+ * Ends all open connections and then shuts down the process with the specified exit code
+ * @param {Number} [code=0] Exit code - defaults to 0
+ */
+const softExit = code => {
+    if(typeof code != "number" || code < 0)
+        code = 0;
+
+    analytics.endSqlConnection().then(() => process.exit(code)).catch(() => process.exit(code));
+}
+
+
+module.exports = { softExit };
+initAll();

+ 130 - 0
src/meter.js

@@ -0,0 +1,130 @@
+// handles custom metering / monitoring values in pm2
+
+const io = require("@pm2/io");
+const fs = require("fs-extra");
+
+const debug = require("./verboseLogging");
+const settings = require("../settings");
+
+var req1mMeter = null;
+var req10mMeter = null;
+var req1hMeter = null;
+var reqtotalMeter = null;
+var submissionMeter = null;
+var m1 = 0;
+var m10 = 0;
+var h1 = 0;
+var tot = 0;
+var subms = 0;
+
+/**
+ * Initializes the meter module
+ * @returns {Promise}
+ */
+function init()
+{
+    return new Promise((resolve, reject) => {
+        try
+        {
+            req1mMeter = io.metric({
+                name: "Reqs / 1m",
+                unit: "req"
+            });
+            req1mMeter.set(-1);
+            setInterval(() => {
+                req1mMeter.set(m1);
+                m1 = 0;
+            }, 1000 * 60);
+
+
+            req10mMeter = io.metric({
+                name: "Reqs / 10m",
+                unit: "req"
+            });
+            req10mMeter.set(-1);
+            setInterval(() => {
+                req10mMeter.set(m10);
+                m10 = 0;
+            }, 1000 * 60 * 10);
+
+
+            req1hMeter = io.metric({
+                name: "Reqs / 1h",
+                unit: "req"
+            });
+            req1hMeter.set(-1);
+            setInterval(() => {
+                req1hMeter.set(h1);
+                h1 = 0;
+            }, 1000 * 60 * 60);
+
+
+            reqtotalMeter = io.metric({
+                name: "Total Reqs",
+                unit: "req"
+            });
+            reqtotalMeter.set(-1);
+
+
+            submissionMeter = io.metric({
+                name: "Submissions",
+                unit: "sub"
+            });
+            subms = fs.readdirSync(settings.jokes.jokeSubmissionPath).length;
+            submissionMeter.set(subms);
+            setInterval(() => {
+                subms = fs.readdirSync(settings.jokes.jokeSubmissionPath).length;
+                submissionMeter.set(subms);
+            }, 1000 * 60 * 10);
+        }
+        catch(err)
+        {
+            return reject(err);
+        }
+
+        return resolve();
+    });
+}
+
+/**
+ * Adds a number to a meter
+ * @param {"req1min"|"req10mins"|"req1hour"|"reqtotal"|"submission"} meterName
+ * @param {Number|undefined} addValue
+ */
+function update(meterName, addValue)
+{
+    if(typeof addValue == "undefined")
+        addValue = 1;
+
+    debug("Meter", `Updating meter ${meterName} - adding value ${addValue}`);
+    
+    if(typeof addValue != "number")
+        throw new TypeError(`meter.update(): "addValue" has wrong type "${typeof addValue}" - expected "number"`);
+
+    switch(meterName)
+    {
+        case "req1min":
+            m1 += addValue;
+        break;
+        case "req10min":
+            m10 += addValue;
+        break;
+        case "req1hour":
+            h1 += addValue;
+        break;
+        case "reqtotal":
+            tot += addValue;
+            reqtotalMeter.set(tot);
+        break;
+        case "submission":
+            subms += addValue;
+            submissionMeter.set(subms);
+        break;
+        default:
+            throw new Error(`meter.update(): "meterName" has incorrect value`);
+    }
+
+    return;
+}
+
+module.exports = { init, update };

+ 418 - 0
src/parseJokes.js

@@ -0,0 +1,418 @@
+// this module parses all the jokes to verify that they are valid and that their structure is not messed up
+
+const fs = require("fs-extra");
+const jsl = require("svjsl");
+
+const settings = require("../settings");
+const debug = require("./verboseLogging");
+const languages = require("./languages");
+const AllJokes = require("./classes/AllJokes");
+const tr = require("./translate");
+
+
+/**
+ * @typedef {Object} CategoryAlias
+ * @prop {String} alias Name of the alias
+ * @prop {String} value The value this alias resolves to
+ */
+
+/** @type {CategoryAlias[]} */
+var categoryAliases = [];
+/** @type {number|undefined} */
+let jokeFormatVersion;
+
+
+/**
+ * Parses all jokes
+ * @returns {Promise<Boolean>}
+ */
+function init()
+{
+    return new Promise((resolve, reject) => {
+        // prepare category aliases
+        Object.keys(settings.jokes.possible.categoryAliases).forEach(alias => {
+            let aliasResolved = settings.jokes.possible.categoryAliases[alias];
+
+            if(!settings.jokes.possible.categories.includes(aliasResolved))
+                return reject(`Error while setting up category aliases: The resolved value "${aliasResolved}" of alias "${alias}" is not present in the "settings.jokes.possible.categories" array.`);
+            
+            categoryAliases.push({ alias, value: aliasResolved });
+        });
+
+        debug("JokeParser", `Registered ${categoryAliases.length} category aliases`);
+
+
+        // prepare jokes files
+        let jokesFiles = fs.readdirSync(settings.jokes.jokesFolderPath);
+        let result = [];
+        let allJokesFilesObj = {};
+
+        let outerPromises = [];
+
+        let parsedJokesAmount = 0;
+
+        jokesFiles.forEach(jf => {
+            if(jf == settings.jokes.jokesTemplateFile)
+                return;
+
+            outerPromises.push(new Promise((resolveOuter, rejectOuter) => {
+                jsl.unused(rejectOuter);
+
+                let fileNameValid = (fileName) => {
+                    if(!fileName.endsWith(".json"))
+                        return false;
+                    let spl1 = fileName.split(".json")[0];
+                    if(spl1.includes("-") && languages.isValidLang(spl1.split("-")[1]) === true && spl1.split("-")[0] == "jokes")
+                        return true;
+                    return false;
+                };
+
+                let getLangCode = (fileName) => {
+                    if(!fileName.endsWith(".json"))
+                        return false;
+                    let spl1 = fileName.split(".json")[0];
+                    if(spl1.includes("-") && languages.isValidLang(spl1.split("-")[1]) === true)
+                        return spl1.split("-")[1].toLowerCase();
+                };
+
+                let langCode = getLangCode(jf);
+
+                if(!jf.endsWith(".json") || !fileNameValid(jf))
+                    result.push(`${jsl.colors.fg.red}Error: Invalid file "${settings.jokes.jokesFolderPath}${jf}" found. It has to follow this pattern: "jokes-xy.json"`);
+
+
+                fs.readFile(`${settings.jokes.jokesFolderPath}${jf}`, (err, jokesFile) => {
+                    if(err)
+                        return reject(err);
+
+                    try
+                    {
+                        jokesFile = JSON.parse(jokesFile.toString());
+                    }
+                    catch(err)
+                    {
+                        return reject(`Error while parsing jokes file "${jf}" as JSON: ${err}`);
+                    }
+
+                    //#MARKER format version
+                    if(jokesFile.info.formatVersion == settings.jokes.jokesFormatVersion)
+                        result.push(true);
+                    else result.push(`Joke file format version of language "${langCode}" is set to "${jokesFile.info.formatVersion}" - Expected: "${settings.jokes.jokesFormatVersion}"`);
+
+                    jokesFile.jokes.forEach((joke, i) => {
+                        //#MARKER joke ID
+                        if(!jsl.isEmpty(joke.id) && !isNaN(parseInt(joke.id)))
+                            result.push(true);
+                        else result.push(`Joke with index/ID ${i} of language "${langCode}" doesn't have an "id" property or it is invalid`);
+
+                        //#MARKER category
+                        if(settings.jokes.possible.categories.map(c => c.toLowerCase()).includes(joke.category.toLowerCase()))
+                            result.push(true);
+                        else
+                            result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid category (Note: aliases are not allowed here)`);
+
+                        //#MARKER type and actual joke
+                        if(joke.type == "single")
+                        {
+                            if(!jsl.isEmpty(joke.joke))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" doesn't have a "joke" property`);
+                        }
+                        else if(joke.type == "twopart")
+                        {
+                            if(!jsl.isEmpty(joke.setup))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" doesn't have a "setup" property`);
+
+                            if(!jsl.isEmpty(joke.delivery))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" doesn't have a "delivery" property`);
+                        }
+                        else result.push(`Joke with index/ID ${i} of language "${langCode}" doesn't have a "type" property or it is invalid`);
+
+                        //#MARKER flags
+                        if(!jsl.isEmpty(joke.flags))
+                        {
+                            if(!jsl.isEmpty(joke.flags.nsfw) || (joke.flags.nsfw !== false && joke.flags.nsfw !== true))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid "NSFW" flag`);
+
+                            if(!jsl.isEmpty(joke.flags.racist) || (joke.flags.racist !== false && joke.flags.racist !== true))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid "racist" flag`);
+
+                            if(!jsl.isEmpty(joke.flags.sexist) || (joke.flags.sexist !== false && joke.flags.sexist !== true))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid "sexist" flag`);
+
+                            if(!jsl.isEmpty(joke.flags.political) || (joke.flags.political !== false && joke.flags.political !== true))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid "political" flag`);
+
+                            if(!jsl.isEmpty(joke.flags.religious) || (joke.flags.religious !== false && joke.flags.religious !== true))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid "religious" flag`);
+
+                            if(!jsl.isEmpty(joke.flags.explicit) || (joke.flags.explicit !== false && joke.flags.explicit !== true))
+                                result.push(true);
+                            else result.push(`Joke with index/ID ${i} of language "${langCode}" has an invalid "explicit" flag`);
+                        }
+                        else result.push(`Joke with index/ID ${i} of language "${langCode}" doesn't have a "flags" object or it is invalid`);
+
+                        parsedJokesAmount++;
+                    });
+
+                    allJokesFilesObj[langCode] = jokesFile;
+                    return resolveOuter();
+                });
+            }));
+        });
+
+        Promise.all(outerPromises).then(() => {
+            let errors = [];
+
+            result.forEach(res => {
+                if(typeof res === "string")
+                    errors.push(res);
+            });
+
+            let allJokesObj = new AllJokes(allJokesFilesObj);
+
+            let formatVersions = [settings.jokes.jokesFormatVersion];
+            languages.jokeLangs().map(jl => jl.code).sort().forEach(lang => {
+                formatVersions.push(allJokesObj.getFormatVersion(lang));
+            });
+
+            if(!jsl.allEqual(formatVersions))
+                errors.push(`One or more of the jokes files has an invalid format version`);
+
+            module.exports.allJokes = allJokesObj;
+            module.exports.jokeCount = allJokesObj.getJokeCount();
+            module.exports.jokeCountPerLang = allJokesObj.getJokeCountPerLang();
+            module.exports.safeJokes = allJokesObj.getSafeJokes();
+
+            let fmtVer = allJokesObj.getFormatVersion("en");
+            module.exports.jokeFormatVersion = fmtVer;
+            jokeFormatVersion = fmtVer;
+
+
+            debug("JokeParser", `Done parsing all ${parsedJokesAmount} jokes. Errors: ${errors.length === 0 ? jsl.colors.fg.green : jsl.colors.fg.red}${errors.length}${jsl.colors.rst}`);
+
+            if(jsl.allEqual(result) && result[0] === true && errors.length === 0)
+                return resolve();
+            
+            return reject(`Errors:\n- ${errors.join("\n- ")}`);
+        }).catch(err => {
+            return reject(err);
+        });
+    });
+}
+
+/**
+ * @typedef {"Misc"|"Programming"|"Dark"|"Pun"|"Spooky"|"Christmas"} JokeCategory Resolved category name (not an alias)
+ */
+/**
+ * @typedef {"Miscellaneous"|"Coding"|"Development"|"Halloween"} JokeCategoryAlias Category name aliases
+ */
+
+/**
+ * @typedef {Object} SingleJoke A joke of type single
+ * @prop {JokeCategory} category The category of the joke
+ * @prop {"single"} type The type of the joke
+ * @prop {String} joke The joke itself
+ * @prop {Object} flags
+ * @prop {Boolean} flags.nsfw Whether the joke is NSFW or not
+ * @prop {Boolean} flags.racist Whether the joke is racist or not
+ * @prop {Boolean} flags.religious Whether the joke is religiously offensive or not
+ * @prop {Boolean} flags.political Whether the joke is politically offensive or not
+ * @prop {Boolean} flags.explicit Whether the joke contains explicit language
+ * @prop {Number} id The ID of the joke
+ * @prop {String} lang The language of the joke
+ */
+
+/**
+ * @typedef {Object} TwopartJoke A joke of type twopart
+ * @prop {JokeCategory} category The category of the joke
+ * @prop {"twopart"} type The type of the joke
+ * @prop {String} setup The setup of the joke
+ * @prop {String} delivery The delivery of the joke
+ * @prop {Object} flags
+ * @prop {Boolean} flags.nsfw Whether the joke is NSFW or not
+ * @prop {Boolean} flags.racist Whether the joke is racist or not
+ * @prop {Boolean} flags.religious Whether the joke is religiously offensive or not
+ * @prop {Boolean} flags.political Whether the joke is politically offensive or not
+ * @prop {Boolean} flags.explicit Whether the joke contains explicit language
+ * @prop {Number} id The ID of the joke
+ * @prop {String} lang The language of the joke
+ */
+
+/**
+ * Validates a single joke passed as a parameter
+ * @param {(SingleJoke|TwopartJoke)} joke A joke object of type single or twopart
+ * @param {String} lang Language code
+ * @returns {(Boolean|Array<String>)} Returns true if the joke has the correct format, returns string array containing error(s) if invalid
+ */
+function validateSingle(joke, lang)
+{
+    let jokeErrors = [];
+
+    if(languages.isValidLang(lang) !== true)
+        jokeErrors.push(tr(lang, "parseJokesInvalidLanguageCode"));
+
+
+    // reserialize object
+    if(typeof joke == "object")
+            joke = JSON.stringify(joke);
+
+    joke = JSON.parse(joke);
+
+
+    // TODO: version 2.3.2:
+    // let jokeObj = {
+    //     "formatVersion": true,
+    //     "category": true,
+    //     "type": true,
+    // };
+
+    // if(joke.type == "single")
+    //     jokeObj.joke = true;
+    // else if(joke.type == "twopart")
+    // {
+    //     jokeObj.setup = true;
+    //     jokeObj.delivery = true;
+    // }
+
+    // jokeObj = {
+    //     ...jokeObj,
+    //     flags: {
+    //         nsfw: true,
+    //         religious: true,
+    //         political: true,
+    //         racist: true,
+    //         sexist: true
+    //     },
+    //     lang: true
+    // }
+
+
+    try
+    {
+        //#MARKER format version
+        if(joke.formatVersion != null)
+        {
+            if(joke.formatVersion != settings.jokes.jokesFormatVersion || joke.formatVersion != jokeFormatVersion)
+            {
+                jokeErrors.push(tr(lang, "parseJokesFormatVersionMismatch", joke.formatVersion, jokeFormatVersion));
+                // jokeObj.formatVersion = false; // TODO: version 2.3.2: repeat this for everything below
+            }
+        }
+        else jokeErrors.push(tr(lang, "parseJokesNoFormatVersionOrInvalid"));
+
+        //#MARKER type and actual joke
+        if(joke.type == "single")
+        {
+            if(jsl.isEmpty(joke.joke))
+                jokeErrors.push(tr(lang, "parseJokesSingleNoJokeProperty"));
+        }
+        else if(joke.type == "twopart")
+        {
+            if(jsl.isEmpty(joke.setup))
+                jokeErrors.push(tr(lang, "parseJokesTwopartNoSetupProperty"));
+
+            if(jsl.isEmpty(joke.delivery))
+                jokeErrors.push(tr(lang, "parseJokesTwopartNoDeliveryProperty"));
+        }
+        else jokeErrors.push(tr(lang, "parseJokesNoTypeProperty"));
+
+        //#MARKER joke category
+        let jokeCat = typeof joke.category === "string" ? resolveCategoryAlias(joke.category) : joke.category;
+
+        if(joke.category == null)
+            jokeErrors.push(tr(lang, "parseJokesNoCategoryProperty"));
+        else if(typeof jokeCat !== "string")
+            jokeErrors.push(tr(lang, "parseJokesInvalidCategory"));
+        else
+        {
+            let categoryValid = false;
+            settings.jokes.possible.categories.forEach(cat => {
+                if(jokeCat.toLowerCase() === cat.toLowerCase())
+                    categoryValid = true;
+            });
+            if(!categoryValid)
+                jokeErrors.push(tr(lang, "parseJokesInvalidCategory"));
+        }
+
+        //#MARKER flags
+        if(!jsl.isEmpty(joke.flags))
+        {
+            if(jsl.isEmpty(joke.flags.nsfw) || (joke.flags.nsfw !== false && joke.flags.nsfw !== true))
+                jokeErrors.push(tr(lang, "parseJokesNoFlagNsfw"));
+
+            if(jsl.isEmpty(joke.flags.racist) || (joke.flags.racist !== false && joke.flags.racist !== true))
+                jokeErrors.push(tr(lang, "parseJokesNoFlagRacist"));
+            
+            if(jsl.isEmpty(joke.flags.sexist) || (joke.flags.sexist !== false && joke.flags.sexist !== true))
+                jokeErrors.push(tr(lang, "parseJokesNoFlagSexist"));
+
+            if(jsl.isEmpty(joke.flags.political) || (joke.flags.political !== false && joke.flags.political !== true))
+                jokeErrors.push(tr(lang, "parseJokesNoFlagPolitical"));
+
+            if(jsl.isEmpty(joke.flags.religious) || (joke.flags.religious !== false && joke.flags.religious !== true))
+                jokeErrors.push(tr(lang, "parseJokesNoFlagReligious"));
+
+            if(jsl.isEmpty(joke.flags.explicit) || (joke.flags.explicit !== false && joke.flags.explicit !== true))
+                jokeErrors.push(tr(lang, "parseJokesNoFlagExplicit"));
+        }
+        else jokeErrors.push(tr(lang, "parseJokesNoFlagsObject"));
+
+        //#MARKER lang
+        if(jsl.isEmpty(joke.lang))
+            jokeErrors.push(tr(lang, "parseJokesNoLangProperty"));
+        
+        let langV = languages.isValidLang(joke.lang, lang);
+        if(typeof langV === "string")
+            jokeErrors.push(tr(lang, "parseJokesLangPropertyInvalid", langV));
+        else if(langV !== true)
+            jokeErrors.push(tr(lang, "parseJokesNoLangProperty"));
+    }
+    catch(err)
+    {
+        jokeErrors.push(tr(lang, "parseJokesCantParse", err.toString()));
+    }
+
+    if(jsl.isEmpty(jokeErrors))
+        return true;
+    else
+        return jokeErrors;
+}
+
+/**
+ * Returns the resolved value of a joke category alias or returns the initial value if it isn't an alias or is invalid
+ * @param {JokeCategory|JokeCategoryAlias} category A singular joke category or joke category alias
+ * @returns {JokeCategory}
+ */
+function resolveCategoryAlias(category)
+{
+    let cat = category;
+    categoryAliases.forEach(catAlias => {
+        if(typeof category !== "string")
+            throw new TypeError(`Can't resolve category alias because '${category}' is not of type string`);
+
+        if(category.toLowerCase() == catAlias.alias.toLowerCase())
+            cat = catAlias.value;
+    });
+
+    return cat;
+}
+
+/**
+ * Returns the resolved values of an array of joke category aliases or returns the initial values if there are none
+ * @param {JokeCategory[]|JokeCategoryAlias[]} categories An array of joke categories (can contain aliases)
+ * @returns {JokeCategory[]}
+ */
+function resolveCategoryAliases(categories)
+{
+    return categories.map(cat => resolveCategoryAlias(cat));
+}
+
+module.exports = { init, validateSingle, resolveCategoryAlias, resolveCategoryAliases }

+ 118 - 0
src/parseURL.js

@@ -0,0 +1,118 @@
+// this module parses the passed URL, returning an object that is uniform and easy to use
+
+const urlParse = require("url-parse");
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+
+const settings = require("../settings");
+
+/**
+ * @typedef {Object} ParsedUrl
+ * @prop {null} error If not errored, this will be `null`, else it will contain a string with the error message
+ * @prop {String} initialURL The requested URL
+ * @prop {(Array<String>|null)} pathArray If empty, this will be `null`, else it will be an array of the URL path
+ * @prop {(Object|null)} queryParams If empty, this will be `null`, else it will be an object of query parameters
+ */
+
+/**
+ * @typedef {Object} ErroredParsedUrl
+ * @prop {String} error If not errored, this will be `null`, else it will contain a string with the error message
+ * @prop {String} initialURL The requested URL
+ */
+
+/**
+ * Parses the passed URL, returning a fancy object
+ * @param {String} url
+ * @returns {(ParsedUrl|ErroredParsedUrl)}
+ */
+function parseURL(url)
+{
+    try
+    {
+        let trimFirstSlash = u2 => {
+            if(u2[0] == "")
+                u2.shift();
+            return u2;
+        };
+
+        let parsed = urlParse(url);
+
+        let qstrObj = {};
+        let qstrArr = [];
+        let rawQstr = (parsed.query == "" ? null : parsed.query);
+
+        if(rawQstr && rawQstr.startsWith("?"))
+            rawQstr = rawQstr.substr(1);
+
+        if(!jsl.isEmpty(rawQstr) && rawQstr.includes("&"))
+            qstrArr = rawQstr.split("&");
+        else if(!jsl.isEmpty(rawQstr))
+            qstrArr = [rawQstr];
+
+
+        if(qstrArr.length > 0)
+        {
+            qstrArr.forEach(qstrEntry => {
+                if(qstrEntry.includes("="))
+                {
+                    let splitEntry = qstrEntry.split("=");
+                    qstrObj[decodeURIComponent(splitEntry[0])] = decodeURIComponent(splitEntry[1].toLowerCase());
+                }
+                else
+                {
+                    let valuelessEntry = qstrEntry.trim();
+                    qstrObj[decodeURIComponent(valuelessEntry)] = true;
+                }
+            });
+        }
+        else
+            qstrObj = null;
+
+        let retObj = {
+            error: null,
+            initialURL: url,
+            pathArray: trimFirstSlash(parsed.pathname.split("/")),
+            queryParams: qstrObj
+        };
+
+        return retObj;
+    }
+    catch(err)
+    {
+        return {
+            error: err.toString(),
+            initialURL: url
+        };
+    }
+}
+
+function getFileFormatFromQString(qstrObj)
+{
+    if(!jsl.isEmpty(qstrObj.format))
+    {
+        let possibleFormats = Object.keys(JSON.parse(fs.readFileSync(settings.jokes.fileFormatsPath).toString()));
+
+        if(possibleFormats.includes(qstrObj.format))
+            return qstrObj.format;
+        else return settings.jokes.defaultFileFormat.fileFormat;
+    }
+    else return settings.jokes.defaultFileFormat.fileFormat;
+}
+
+/**
+ * Returns the MIME type of the provided file format string (example: "json" -> "application/json")
+ * @param {String} fileFormatString 
+ * @returns {String}
+ */
+function getMimeTypeFromFileFormatString(fileFormatString)
+{
+    let allFileTypes = JSON.parse(fs.readFileSync(settings.jokes.fileFormatsPath).toString());
+
+    if(!jsl.isEmpty(allFileTypes[fileFormatString]))
+        return allFileTypes[fileFormatString].mimeType;
+    else return settings.jokes.defaultFileFormat.mimeType;
+}
+
+module.exports = parseURL;
+module.exports.getFileFormatFromQString = getFileFormatFromQString;
+module.exports.getMimeTypeFromFileFormatString = getMimeTypeFromFileFormatString;

+ 78 - 0
src/resolveIP.js

@@ -0,0 +1,78 @@
+const http = require("http");
+const jsl = require("svjsl");
+const crypto = require("crypto");
+const reqIP = require("request-ip");
+const net = require("net");
+const settings = require("../settings");
+
+jsl.unused(http);
+
+
+
+/**
+ * Extracts the IP address from a HTTP request object
+ * @param {http.ServerResponse} req The HTTP req object
+ * @returns {String}
+ */
+const resolveIP = req => {
+    let ipaddr = null;
+
+    ipaddr = reqIP.getClientIp(req);
+
+    if(ipaddr == null)
+    {
+        if(req.headers && typeof req.headers["cf-pseudo-ipv4"] == "string" && isValidIP(req.headers["cf-pseudo-ipv4"]))
+            ipaddr = req.headers["cf-pseudo-ipv4"];
+    }
+
+    if(ipaddr == null)
+    {
+        if(req.headers && typeof req.headers["cf_ipcountry"] == "string")
+            ipaddr = `unknown_${req.headers["cf_ipcountry"]}`;
+    }
+
+    return settings.httpServer.ipHashing.enabled ? hashIP(ipaddr) : ipaddr;
+};
+
+/**
+ * Checks if an IP is local or not (`localhost`, `127.0.0.1`, `::1`, etc.)
+ * @param {String} ip
+ * @param {Boolean} [inputIsHashed=false] If the input IP is hashed, set this to true
+ * @returns {Boolean}
+ */
+const isLocal = (ip, inputIsHashed = false) => {
+    let localIPs = ["localhost", "127.0.0.1", "::1"];
+    let isLocal = false;
+
+    localIPs.forEach(lIP => {
+        if(inputIsHashed && ip.match(lIP))
+            isLocal = true;
+        else if(!inputIsHashed && ip.match(hashIP(lIP)))
+            isLocal = true;
+    });
+
+    return isLocal;
+};
+
+/**
+ * Checks whether or not an IP address is valid
+ * @param {String} ip
+ * @returns {Boolean}
+ */
+const isValidIP = ip => net.isIP(ip) > 0;
+
+/**
+ * Hashes an IP address with the algorithm defined in `settings.httpServer.ipHashing.algorithm`
+ * @param {String} ip
+ * @returns {String}
+ */
+const hashIP = ip => {
+    let hash = crypto.createHash(settings.httpServer.ipHashing.algorithm);
+    hash.update(ip, "utf8");
+    return hash.digest(settings.httpServer.ipHashing.digest).toString();
+};
+
+module.exports = resolveIP;
+module.exports.isValidIP = isValidIP;
+module.exports.hashIP = hashIP;
+module.exports. isLocal = isLocal;

+ 93 - 0
src/translate.js

@@ -0,0 +1,93 @@
+const fs = require("fs-extra");
+const jsl = require("svjsl");
+
+const debug = require("./verboseLogging")
+const settings = require("../settings");
+
+
+/** Whether this module was initialized */
+let initialized = false;
+let trFile = {};
+
+/**
+ * Initializes the translation module by caching the translations so they only need to be read from disk once
+ * @returns {Promise}
+ */
+function init()
+{
+    debug("Translate", `Initializing - loading translations from "${settings.languages.translationsFile}"`);
+    return new Promise((resolve, reject) => {
+        fs.readFile(settings.languages.translationsFile, (err, res) => {
+            if(err)
+                return reject(`Error while reading translations file: ${err}`);
+            else
+            {
+                trFile = JSON.parse(res.toString());
+                debug("Translate", `Found ${Object.keys(trFile.tr).length} translations`);
+                initialized = true;
+                return resolve();
+            }
+        });
+    });
+}
+
+/**
+ * Returns the translation of a sentence of a specified language.
+ * @param {String} lang Language code
+ * @param {String} id The name of the translation node
+ * @param {...any} args Arguments to replace numbered %-placeholders with. Only use objects that are strings or convertable to them with `.toString()`!
+ * @returns {String|null} Returns `null` if no translation is available. Else returns a string
+ */
+function translate(lang, id, ...args)
+{
+    if(!initialized)
+        throw new Error("translate module isnt't initialized");
+
+    if(!lang)
+        lang = settings.languages.defaultLanguage;
+
+    let langTr = trFile.tr[id];
+    if(!langTr)
+        return null;
+
+    let translation = langTr[lang.toString().toLowerCase()];
+    if(!translation)
+        translation = langTr[settings.languages.defaultLanguage];
+    
+    translation = translation.toString();
+
+    if(Array.isArray(args) && translation.includes("%"))
+    {
+        args.forEach((arg, i) => {
+            let rex = new RegExp(`%${i + 1}`);
+            if(translation.match(rex))
+            {
+                try
+                {
+                    translation = translation.replace(rex, arg.toString());
+                }
+                catch(err)
+                {
+                    jsl.unused(err);
+                }
+            }
+        });
+    }
+
+    // debug("Translate", `Translating "${id}" into ${lang} - result: ${translation}`);
+
+    return translation;
+}
+
+/**
+ * Returns a list of system languages (2 char code)
+ * @returns {Array<String>}
+ */
+function systemLangs()
+{
+    return trFile.languages;
+}
+
+module.exports = translate;
+module.exports.init = init;
+module.exports.systemLangs = systemLangs;

+ 5 - 0
src/types/env.d.ts

@@ -0,0 +1,5 @@
+/** Normalized deployment environment name */
+export type Env = "prod" | "stage";
+
+/** Environment-dependent global property */
+export type EnvDependentProp = "name" | "httpPort" | "baseUrl";

+ 84 - 0
src/types/jokes.d.ts

@@ -0,0 +1,84 @@
+//#SECTION dependency types
+
+/** All joke types */
+export type JokeType = "single" | "twopart";
+
+/** All joke categories (excluding aliases) */
+export type JokeCategory = "Misc" | "Programming" | "Dark" | "Pun" | "Spooky" | "Christmas";
+
+/** All blacklist flags */
+export interface JokeFlags {
+    nsfw: boolean;
+    racist: boolean;
+    religious: boolean;
+    political: boolean;
+    sexist: boolean;
+    explicit: boolean;
+}
+
+
+//#SECTION base interfaces
+
+/** Base interface for all kinds of jokes, internal or submission */
+declare interface JokeBase {
+    category: JokeCategory;
+    type: JokeType;
+    flags: JokeFlags;
+}
+
+/** Base interface for internal jokes (ones that are saved to the local JSON files) */
+declare interface InternalJokeBase extends JokeBase {
+    safe: boolean;
+    id: number;
+}
+
+/** Base interface for joke submissions */
+declare interface SubmissionBase extends JokeBase {
+    formatVersion: number;
+}
+
+
+//#SECTION internal jokes
+
+/** An internal joke of type `single` */
+export interface SingleJoke extends InternalJokeBase {
+    type: "single";
+    joke: string;
+}
+
+/** An internal joke of type `twopart` */
+export interface TwopartJoke extends InternalJokeBase {
+    type: "twopart";
+    setup: string;
+    delivery: string;
+}
+
+/** An internal joke of any type */
+export type Joke = SingleJoke | TwopartJoke;
+
+/** Represents an internal joke file */
+export interface JokesFile {
+    info: {
+        formatVersion: number;
+    }
+    jokes: Joke[];
+}
+
+
+//#SECTION submissions
+
+/** A joke submission of type `single` */
+export interface JokeSubmissionSingle extends SubmissionBase {
+    type: "single";
+    joke: string;
+}
+
+/** A joke submission of type `twopart` */
+export interface JokeSubmissionTwopart extends SubmissionBase {
+    type: "twopart";
+    setup: string;
+    delivery: string;
+}
+
+/** A joke submission of any type */
+export type JokeSubmission = JokeSubmissionSingle | JokeSubmissionTwopart;

+ 9 - 0
src/types/languages.d.ts

@@ -0,0 +1,9 @@
+import * as LangFile from "../../data/languages.json";
+
+declare type LangFileType = typeof LangFile;
+
+
+/** All language codes JokeAPI supports */
+export type LangCode = keyof LangFileType;
+/** The default / fallback language code */
+export type DefaultLangCode = "en";

+ 21 - 0
src/verboseLogging.js

@@ -0,0 +1,21 @@
+// handles verbose logging
+
+const jsl = require("svjsl");
+const settings = require("../settings");
+
+const col = jsl.colors;
+
+/**
+ * Logs a preformatted message to the console if `settings.debug.verboseLogging` is set to `true`, else does nothing
+ * @param {String} section
+ * @param {String} message
+ */
+function verboseLogging(section, message)
+{
+    if(settings.debug.verboseLogging !== true)
+        return;
+    
+    console.log(`${col.fg.yellow}[DBG/${col.rst}${col.fg.blue}${section}${col.rst}${col.fg.yellow}]${col.rst} - ${message}`);
+}
+
+module.exports = verboseLogging;

+ 153 - 0
tests/info-endpoint.js

@@ -0,0 +1,153 @@
+const { XMLHttpRequest } = require("xmlhttprequest");
+// const jsl = require("svjsl");
+const semver = require("semver");
+
+const settings = require("../settings");
+
+const meta = {
+    name: "Info",
+    category: "Endpoints"
+};
+
+const baseURL = `http://127.0.0.1:${settings.httpServer.port}`;
+
+
+/**
+ * @typedef {Object} UnitTestResult
+ * @prop {Object} meta
+ * @prop {String} meta.name
+ * @prop {String} meta.category
+ * @prop {Array<String>} errors
+ */
+
+/**
+ * Runs this unit test
+ * @returns {Promise<UnitTestResult>}
+ */
+function run()
+{
+    return new Promise((resolve, reject) => {
+        let errors = [];
+
+
+        let run = () => new Promise(xhrResolve => {
+            let xhr = new XMLHttpRequest();
+            xhr.open("GET", `${baseURL}/info`);
+
+            xhr.onreadystatechange = () => {
+                if(xhr.readyState == 4)
+                {
+                    if(xhr.status >= 200 && xhr.status < 300)
+                    {
+                        let resp = JSON.parse(xhr.responseText);
+                        let packageJSON = require("../package.json");
+
+                        //#SECTION error
+                        if(resp.error == true)
+                        {
+                            errors.push(`"error" parameter is set to "true" - error message: ${resp.message}`);
+                            return xhrResolve();
+                        }
+
+                        //#SECTION version
+                        if(!semver.eq(resp.version, packageJSON.version))
+                            errors.push(`API version (${resp.version}) doesn't match version in package.json (${packageJSON.version})`);
+
+                        //#SECTION joke count
+                        if(!resp.jokes.totalCount || isNaN(parseInt(resp.jokes.totalCount)))
+                            errors.push(`API supplied no "totalCount" param or it is not a number`);
+                        
+                        //#SECTION categories
+                        let possibleCats = [settings.jokes.possible.anyCategoryName, ...settings.jokes.possible.categories];
+                        if(!arraysEqual(possibleCats, resp.jokes.categories))
+                            errors.push(`API's categories (${resp.jokes.categories.map(c => c.substring(0, 5)).join(", ")}) differ from the local ones (${possibleCats.map(c => c.substring(0, 5)).join(", ")})`);
+
+                        //#SECTION flag
+                        let possibleFlags = settings.jokes.possible.flags;
+                        if(!arraysEqual(possibleFlags, resp.jokes.flags))
+                            errors.push(`API's flags (${resp.jokes.flags.join(", ")}) differ from the local ones (${possibleFlags.join(", ")})`);
+
+                        //#SECTION types
+                        let possibleTypes = settings.jokes.possible.types;
+                        if(!arraysEqual(possibleTypes, resp.jokes.types))
+                            errors.push(`API's types (${resp.jokes.types.join(", ")}) differ from the local ones (${possibleTypes.join(", ")})`);
+
+                        //#SECTION formats
+                        let possibleFormats = settings.jokes.possible.formats;
+                        if(!arraysEqual(possibleFormats, resp.formats))
+                            errors.push(`API's formats (${resp.formats.join(", ")}) differ from the local ones (${possibleFormats.join(", ")})`);
+                        
+                        //#SECTION joke languages
+                        if(!resp.jokeLanguages || isNaN(parseInt(resp.jokeLanguages)))
+                            errors.push(`API supplied no "jokeLanguages" param or it is not a number`);
+
+                        //#SECTION system languages
+                        if(!resp.systemLanguages || isNaN(parseInt(resp.systemLanguages)))
+                            errors.push(`API supplied no "systemLanguages" param or it is not a number`);
+
+                        //#SECTION info string
+                        if(typeof resp.info != "string")
+                            errors.push(`API supplied no "info" param or it is not a string`);
+
+                        //#SECTION timestamp
+                        let resTS = parseInt(resp.timestamp);
+                        let localTS = parseInt(new Date().getTime());
+                        let tsRange = [localTS - 600000, localTS + 600000];
+                        if(resTS < tsRange[0] || resTS > tsRange[1])
+                            errors.push(`API system's time is out of sync by more than 10 minutes`);
+                        
+                        return xhrResolve();
+                    }
+                    else
+                    {
+                        errors.push(`Couldn't reach endpoint - HTTP status: ${xhr.status}`);
+                        return xhrResolve();
+                    }
+                }
+            };
+
+            xhr.send();
+        });
+
+        run().then(() => {
+            if(errors.length == 0)
+                return resolve({ meta });
+            else
+                return reject({ meta, errors });
+        });
+    });
+}
+
+
+/**
+ * Checks if two arrays contain the same elements (order is ignored)
+ * @author [canbax](https://stackoverflow.com/a/55614659/8602926)
+ * @param {Array<*>} a1 
+ * @param {Array<*>} a2 
+ */
+function arraysEqual(a1, a2) {
+    const superSet = {};
+    for(const i of a1)
+    {
+        const e = i + typeof i;
+        superSet[e] = 1;
+    }
+  
+    for(const i of a2)
+    {
+        const e = i + typeof i;
+        if(!superSet[e])
+            return false;
+        superSet[e] = 2;
+    }
+  
+    for(let e in superSet)
+    {
+        if(superSet[e] === 1)
+            return false;
+    }
+
+    return true;
+}
+
+module.exports = { meta, run };

+ 28 - 0
tests/joke-endpoint.js

@@ -0,0 +1,28 @@
+/* eslint-disable */
+
+const settings = require("../settings");
+
+const meta = {
+    name: "Joke",
+    category: "Endpoints",
+    endpointURL: "/joke"
+};
+
+/**
+ * @typedef {Object} UnitTestResult
+ * @prop {String} name
+ * @prop {Boolean} success
+ */
+
+/**
+ * Runs this unit test
+ * @returns {Promise<Array<UnitTestResult>, String>}
+ */
+function run()
+{
+    return new Promise((resolve, reject) => {
+        return resolve({ meta });
+    });
+}
+
+module.exports = { meta, run };

+ 98 - 0
tests/langcode-endpoint.js

@@ -0,0 +1,98 @@
+const { XMLHttpRequest } = require("xmlhttprequest");
+const jsl = require("svjsl");
+
+const settings = require("../settings");
+
+const meta = {
+    name: "Langcode",
+    category: "Endpoint"
+};
+
+const baseURL = `http://127.0.0.1:${settings.httpServer.port}`;
+
+
+/**
+ * @typedef {Object} UnitTestResult
+ * @prop {Object} meta
+ * @prop {String} meta.name
+ * @prop {String} meta.category
+ * @prop {Array<String>} errors
+ */
+
+/**
+ * Runs this unit test
+ * @returns {Promise<UnitTestResult>}
+ */
+function run()
+{
+    return new Promise((resolve, reject) => {
+        let errors = [];
+
+        let run = () => new Promise(runResolve => {
+            let sendXHR = language => {
+                return new Promise((xhrResolve, xhrReject) => {
+                    jsl.unused(xhrReject);
+                    let xhr = new XMLHttpRequest();
+                    xhr.open("GET", `${baseURL}/langcode/${language}`); // < set endpoint here
+
+                    xhr.onreadystatechange = () => {
+                        if(xhr.readyState == 4)
+                        {
+                            if(xhr.status >= 200 && xhr.status < 300)
+                            {
+                                let resp = JSON.parse(xhr.responseText);
+                                return xhrResolve({ input: language, code: resp.code || null });
+                            }
+                            else if(xhr.status == 400)
+                                return xhrResolve({ input: language, code: null });
+                            else
+                            {
+                                errors.push(`Couldn't reach endpoint - HTTP status: ${xhr.status}`);
+                                return runResolve();
+                            }
+                        }
+                    };
+
+                    xhr.send();
+                });
+            };
+
+            let promises = [];
+            let langs = [
+                { lang: "german", expectedCode: "de" },
+                { lang: "g3rm4n", expectedCode: "de" },
+                { lang: "Azerbaijani", expectedCode: "az" },
+                { lang: "Luxembourg", expectedCode: "lb" },
+                { lang: "invalid_language_xyz", expectedCode: null }
+            ];
+            
+            langs.forEach(l => {
+                let lang = l.lang;
+                promises.push(sendXHR(lang));
+            });
+            
+            Promise.all(promises).then(vals => {
+                vals.forEach(val => {
+                    if(typeof val == "object")
+                    {
+                        let filterRes = langs.filter(lval => lval.lang == val.input)[0];
+                        if(filterRes.expectedCode != val.code)
+                            errors.push(`Code of language "${val.input}" didn't match the expected value (expected "${filterRes.expectedCode}" but got "${val.code}")`);
+                    }
+                });
+
+                return runResolve();
+            });
+        });
+
+
+        run().then(() => {
+            if(errors.length == 0)
+                return resolve({ meta });
+            else
+                return reject({ meta, errors });
+        });
+    });
+}
+
+module.exports = { meta, run };

+ 131 - 0
tests/safe-mode.js

@@ -0,0 +1,131 @@
+const { XMLHttpRequest } = require("xmlhttprequest");
+const jsl = require("svjsl");
+
+const settings = require("../settings");
+
+
+const baseURL = `http://127.0.0.1:${settings.httpServer.port}`;
+const requestAmount = 50;
+const defaultLang = "en";
+
+
+const meta = {
+    name: "Safe Mode",
+    category: "Parameter"
+};
+
+
+/**
+ * @typedef {Object} UnitTestResult
+ * @prop {Object} meta
+ * @prop {String} meta.name
+ * @prop {String} meta.category
+ * @prop {Array<String>} errors
+ */
+
+/**
+ * Runs this unit test
+ * @returns {Promise<UnitTestResult>}
+ */
+function run()
+{
+    return new Promise((resolve, reject) => {
+        let errors = [];
+        let languages = [];
+
+        const run = () => new Promise(xhrResolve => {
+            const get = () => {
+                if(languages.length == 0)
+                    languages = [defaultLang];
+
+                return new Promise((pRes, pRej) => {
+                    let xhr = new XMLHttpRequest();
+                    let langCode = jsl.randomItem(languages);
+                    xhr.open("GET", `${baseURL}/joke/Any?safe-mode&lang=${langCode}`);
+
+                    xhr.onreadystatechange = () => {
+                        if(xhr.readyState == 4)
+                        {
+                            let respText = {};
+                            try
+                            {
+                                respText = JSON.parse(xhr.responseText);
+                            }
+                            catch(err)
+                            {
+                                errors.push(`Couldn't parse API response as JSON: ${err}`);
+                                jsl.unused(err);
+                            }
+
+                            if(respText.safe === false)
+                                errors.push(`Joke #${respText.id} is unsafe`);
+
+                            if(xhr.status < 300 && xhr.status != 0)
+                                return pRes({
+                                    i: respText.id,
+                                    s: respText.safe
+                                });
+                            else
+                            {
+                                errors.push(`Couldn't reach endpoint - HTTP status: ${xhr.status}`);
+                                return pRej(xhr.status);
+                            }
+                        }
+                    };
+
+                    xhr.send();
+                });
+            }
+
+            let langXhr = new XMLHttpRequest();
+            langXhr.open("GET", `${baseURL}/languages`);
+            langXhr.onreadystatechange = () => {
+                if(langXhr.readyState == 4)
+                {
+                    if(langXhr.status < 300)
+                    {
+                        try
+                        {
+                            let data = JSON.parse(langXhr.responseText);
+
+                            if(data.jokeLanguages)
+                                languages = data.jokeLanguages;
+                        }
+                        catch(err)
+                        {
+                            jsl.unused(err);
+                        }
+
+                        let promises = [];
+                        for(let i = 0; i < requestAmount; i++)
+                            promises.push(get());
+
+                        Promise.all(promises).then(() => {
+                            return xhrResolve();
+                        }).catch(err => {
+                            jsl.unused(err);
+                            return xhrResolve();
+                        });
+                    }
+                    else
+                    {
+                        errors.push(`Endpoint "languages" not available - status: ${langXhr.status}`);
+                        return xhrResolve();
+                    }
+                }
+            };
+
+            langXhr.send();
+        });
+
+
+        run().then(() => {
+            if(errors.length == 0)
+                return resolve({ meta });
+            else
+                return reject({ meta, errors });
+        });
+    });
+}
+
+module.exports = { meta, run };

+ 63 - 0
tests/template.js

@@ -0,0 +1,63 @@
+const { XMLHttpRequest } = require("xmlhttprequest");
+// const jsl = require("svjsl");
+
+const settings = require("../settings");
+
+const meta = {
+    name: "Test Name",
+    category: "Test Category"
+};
+
+const baseURL = `http://127.0.0.1:${settings.httpServer.port}`;
+
+
+/**
+ * @typedef {Object} UnitTestResult
+ * @prop {Object} meta
+ * @prop {String} meta.name
+ * @prop {String} meta.category
+ * @prop {Array<String>} errors
+ */
+
+/**
+ * Runs this unit test
+ * @returns {Promise<UnitTestResult>}
+ */
+function run()
+{
+    return new Promise((resolve, reject) => {
+        let errors = [];
+
+        let run = () => new Promise(xhrResolve => {
+            let xhr = new XMLHttpRequest();
+            xhr.open("GET", `${baseURL}/ENDPOINT`); // < set endpoint here
+
+            xhr.onreadystatechange = () => {
+                if(xhr.readyState == 4)
+                {
+                    if(xhr.status >= 200 && xhr.status < 300)
+                    {
+                        // unit tests here
+                    }
+                    else
+                    {
+                        errors.push(`Couldn't reach endpoint - HTTP status: ${xhr.status}`);
+                        return xhrResolve();
+                    }
+                }
+            };
+
+            xhr.send();
+        });
+
+
+        run().then(() => {
+            if(errors.length == 0)
+                return resolve({ meta });
+            else
+                return reject({ meta, errors });
+        });
+    });
+}
+
+module.exports = { meta, run };

+ 21 - 0
tools/README.md

@@ -0,0 +1,21 @@
+# JokeAPI CLI tools
+Since v2.3.2, JokeAPI has a globally callable command line binary, which acts as an interface to all command-line tools inside this `./tools` folder.
+
+<br>
+
+## Setup:
+To register the JokeAPI binary, run the command `npm run link`  
+If you get an `EACCES` error, try using `sudo npm run link`, otherwise you probably need to reinstall Node.js through a version manager like [nvm](https://github.com/nvm-sh/nvm)  
+  
+Afterwards, the binary will be globally callable with the commands `jokeapi` and `japi`  
+  
+To display a list of all commands, run `jokeapi -h`  
+To get command-specific help and show the command's arguments, run `jokeapi -h <command>`
+
+<br>
+
+## Commands:
+| Command | Description |
+| :-- | :-- |
+| `jokeapi start` | Starts JokeAPI (equivalent to running `npm start` or `node .`) |
+| `jokeapi info` | Prints information about JokeAPI, like the /info endpoint |

+ 477 - 0
tools/add-joke.js

@@ -0,0 +1,477 @@
+const prompt = require("prompts");
+const { colors, Errors, isEmpty, filesystem, reserialize } = require("svcorelib");
+const { writeFile, copyFile, readFile } = require("fs-extra");
+const { join } = require("path");
+
+const languages = require("../src/languages");
+const translate = require("../src/translate");
+const parseJokes = require("../src/parseJokes");
+const { validateSingle } = parseJokes;
+const { reformatJoke } = require("../src/jokeSubmission");
+
+const settings = require("../settings");
+
+const col = colors.fg;
+const { exit } = process;
+
+/** Global data that persists until the process exits */
+const data = {
+    /** Whether the init() function has been called yet */
+    initialized: false,
+};
+
+//#SECTION types
+
+/** @typedef {import("tsdef").NullableProps} NullableProps */
+/** @typedef {import("./types").AddJoke} AddJoke */
+/** @typedef {import("./types").Keypress} Keypress */
+/** @typedef {import("../src/types/jokes").Joke} Joke */
+/** @typedef {import("../src/types/jokes").JokeSubmission} JokeSubmission */
+
+
+//#MARKER init
+
+//#SECTION on execute
+
+try
+{
+    if(!process.stdin.isTTY)
+        throw new Errors.NoStdinError("The process doesn't have an stdin channel to read input from");
+    else
+        run();
+}
+catch(err)
+{
+    exitError(err);
+}
+
+/**
+ * Prints an error and instantly queues exit with status 1 (all async tasks are immediately canceled)
+ * @param {Error} err
+ */
+function exitError(err)
+{
+    if(!(err instanceof Error))
+    {
+        console.error(`\n${col.red}${err.toString()}${col.rst}\n`);
+        exit(1);
+    }
+
+    const stackLines = err.stack.toString().split(/\n/g);
+    stackLines.shift();
+    const stackStr = stackLines.join("\n");
+    console.error(`\n${col.red}${err.message.match(/(E|e)rror/) ? "" : "Error: "}${err.message}${col.rst}\n${stackStr}\n`);
+
+    exit(1);
+}
+
+/**
+ * Runs this tool
+ * @param {AddJoke} [incompleteJoke]
+ */
+async function run(incompleteJoke = undefined)
+{
+    try
+    {
+        if(!data.initialized)
+            await init();
+
+        data.initialized = true;
+
+        const joke = await promptJoke(incompleteJoke);
+
+        await addJoke(joke);
+
+        blankLine();
+
+        const { another } = await prompt({
+            type: "confirm",
+            message: "Add another joke?",
+            name: "another",
+            initial: false,
+        });
+
+        if(another)
+        {
+            blankLine(2);
+            return run();
+        }
+
+        blankLine();
+
+        exit(0);
+    }
+    catch(err)
+    {
+        exitError(err);
+    }
+}
+
+/**
+ * Initializes the add-joke script
+ * @returns {Promise<void, Error>}
+ */
+function init()
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            await languages.init();
+
+            await translate.init();
+
+            await parseJokes.init();
+
+            return res();
+        }
+        catch(err)
+        {
+            const e = new Error(`Couldn't initialize: ${err.message}`).stack += err.stack;
+            return rej(e);
+        }
+    });
+}
+
+//#MARKER prompts
+
+/**
+ * Prompts the user to enter all joke properties
+ * @param {Joke} currentJoke
+ * @returns {Promise<Joke, Error>}
+ */
+function promptJoke(currentJoke)
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            if(!currentJoke)
+                currentJoke = createEmptyJoke();
+
+            /**
+             * Makes a title for the prompt below
+             * @param {string} propName Name of the property (case sensitive)
+             * @param {string} curProp The current value of the property to display
+             * @returns {string}
+             */
+            const makeTitle = (propName, curProp) => {
+                const truncateLength = 64;
+                
+                if(typeof curProp === "string" && curProp.length > truncateLength)
+                    curProp = `${curProp.substr(0, truncateLength)}…`;
+
+                const boolDeco = typeof curProp === "boolean" ? (curProp === true ? ` ${col.green}✔ ` : ` ${col.red}✘ `) : "";
+
+                const propCol = curProp != null ? col.green : col.magenta;
+
+                return `${propName}${col.rst} ${propCol}(${col.rst}${curProp}${col.rst}${boolDeco}${propCol})${col.rst}`;
+            };
+
+            const jokeChoices = currentJoke.type === "single" ? [
+                {
+                    title: makeTitle("Joke", currentJoke.joke),
+                    value: "joke",
+                },
+            ] : [
+                {
+                    title: makeTitle("Setup", currentJoke.setup),
+                    value: "setup",
+                },
+                {
+                    title: makeTitle("Delivery", currentJoke.delivery),
+                    value: "delivery",
+                },
+            ];
+
+            const choices = [
+                {
+                    title: makeTitle("Category", currentJoke.category),
+                    value: "category",
+                },
+                {
+                    title: makeTitle("Type", currentJoke.type),
+                    value: "type",
+                },
+                ...jokeChoices,
+                {
+                    title: makeTitle("Flags", extractFlags(currentJoke)),
+                    value: "flags",
+                },
+                {
+                    title: makeTitle("Language", currentJoke.lang),
+                    value: "lang",
+                },
+                {
+                    title: makeTitle("Safe", currentJoke.safe),
+                    value: "safe",
+                },
+                {
+                    title: `${col.green}[Submit]${col.rst}`,
+                    value: "submit",
+                },
+                {
+                    title: `${col.red}[Exit]${col.rst}`,
+                    value: "exit",
+                },
+            ];
+
+            process.stdout.write("\n");
+
+            const { editProperty } = await prompt({
+                message: "Edit new joke's properties",
+                type: "select",
+                name: "editProperty",
+                hint: "- Use arrow-keys. Return to select. Esc or Ctrl+C to submit.",
+                choices,
+            });
+
+            switch(editProperty)
+            {
+            case "category":
+            {
+                const catChoices = settings.jokes.possible.categories.map(cat => ({ title: cat, value: cat }));
+
+                const { category } = await prompt({
+                    type: "select",
+                    message: `Select new category`,
+                    name: "category",
+                    choices: catChoices,
+                    initial: settings.jokes.possible.categories.indexOf("Misc"),
+                });
+
+                currentJoke.category = category;
+                break;
+            }
+            case "joke":
+            case "setup":
+            case "delivery":
+                currentJoke[editProperty] = (await prompt({
+                    type: "text",
+                    message: `Enter value for '${editProperty}' property`,
+                    name: "val",
+                    initial: currentJoke[editProperty] || "",
+                    validate: (val) => (!isEmpty(val) && val.length >= settings.jokes.submissions.minLength),
+                })).val;
+                break;
+            case "type":
+                currentJoke.type = (await prompt({
+                    type: "select",
+                    message: "Select a joke type",
+                    choices: [
+                        { title: "Single", value: "single" },
+                        { title: "Two Part", value: "twopart" },
+                    ],
+                    name: "type",
+                })).type;
+                break;
+            case "flags":
+            {
+                const flagKeys = Object.keys(currentJoke.flags);
+                const flagChoices = [];
+
+                flagKeys.forEach(key => {
+                    flagChoices.push({
+                        title: key,
+                        selected: currentJoke.flags[key] === true,
+                    });
+                });
+
+                const { newFlags } = await prompt({
+                    type: "multiselect",
+                    message: "Edit joke flags",
+                    choices: flagChoices,
+                    name: "newFlags",
+                    instructions: false,
+                    hint: "- arrow-keys to move, space to toggle, return to submit",
+                });
+
+                Object.keys(currentJoke.flags).forEach(key => {
+                    currentJoke.flags[key] = false;
+                });
+
+                newFlags.forEach(setFlagIdx => {
+                    const key = flagKeys[setFlagIdx];
+                    currentJoke.flags[key] = true;
+                });
+
+                break;
+            }
+            case "lang":
+                currentJoke.lang = (await prompt({
+                    type: "text",
+                    message: "Enter joke language",
+                    initial: currentJoke.lang,
+                    name: "lang",
+                    validate: ((val) => languages.isValidLang(val, "en") === true),
+                })).lang;
+                break;
+            case "safe":
+                currentJoke.safe = (await prompt({
+                    type: "confirm",
+                    message: "Is this joke safe?",
+                    initial: true,
+                    name: "safe",
+                })).safe;
+                break;
+            case "submit":
+                return res(currentJoke);
+            case "exit":
+            {
+                const { confirmExit } = await prompt({
+                    type: "confirm",
+                    message: "Do you really want to exit?",
+                    name: "confirmExit",
+                    initial: true,
+                });
+
+                confirmExit && exit(0);
+                break;
+            }
+            default:
+                return exitError(new Error(`Selected invalid option '${editProperty}'`));
+            }
+
+            return res(await promptJoke(currentJoke));
+        }
+        catch(err)
+        {
+            const e = new Error(`Error while prompting for joke: ${err.message}`).stack += err.stack;
+            return rej(e);
+        }
+    });
+}
+
+//#MARKER other
+
+/**
+ * Adds a joke to its language file
+ * @param {AddJoke} joke
+ * @returns {Promise<void, (Error)>}
+ */
+function addJoke(joke)
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            const initialJoke = reserialize(joke);
+            const { lang } = joke;
+
+            const jokeFilePath = join(settings.jokes.jokesFolderPath, `jokes-${lang}.json`);
+            const templatePath = join(settings.jokes.jokesFolderPath, settings.jokes.jokesTemplateFile);
+
+            if(!(await filesystem.exists(jokeFilePath)))
+                await copyFile(templatePath, jokeFilePath);
+
+
+            /** @type {JokesFile} */
+            const currentJokesFile = JSON.parse((await readFile(jokeFilePath)).toString());
+            /** @type {any} */
+            const currentJokes = reserialize(currentJokesFile.jokes);
+
+            const lastId = currentJokes[currentJokes.length - 1].id;
+
+            const validationRes = validateSingle(joke, lang);
+
+            // ensure props match and strip extraneous props
+            joke.id = lastId + 1;
+            joke.lang && delete joke.lang;
+            joke.formatVersion && delete joke.formatVersion;
+
+            joke = reformatJoke(joke);
+
+            if(Array.isArray(validationRes))
+            {
+                console.error(`\n${col.red}Joke is invalid:${col.rst}\n  - ${validationRes.join("\n  - ")}\n`);
+
+                const { retry } = await prompt({
+                    type: "confirm",
+                    message: "Do you want to retry?",
+                    name: "retry",
+                    initial: true,
+                });
+
+                if(retry)
+                    return promptJoke(initialJoke);
+
+                exit(0);
+            }
+            else
+            {
+                currentJokes.push(joke);
+
+                currentJokesFile.jokes = currentJokes;
+
+                await writeFile(jokeFilePath, JSON.stringify(currentJokesFile, undefined, 4));
+
+                return res();
+            }
+        }
+        catch(err)
+        {
+            const e = new Error(`Couldn't save joke: ${err.message}`).stack += err.stack;
+            return rej(e);
+        }
+    });
+}
+
+//#SECTION prompt deps
+
+/**
+ * Extracts flags of a joke submission, returning a string representation
+ * @param {JokeSubmission} joke
+ * @returns {string} Returns flags delimited with `, ` or "none" if no flags are set
+ */
+function extractFlags(joke)
+{
+    /** @type {JokeFlags[]} */
+    const flags = [];
+
+    Object.keys(joke.flags).forEach(key => {
+        if(joke.flags[key] === true)
+            flags.push(key);
+    });
+
+    return flags.length > 0 ? flags.join(", ") : "none";
+}
+
+//#SECTION other deps
+
+/**
+ * Returns a joke where everything is set to a default but empty value
+ * @returns {NullableProps<AddJoke>}
+ */
+function createEmptyJoke()
+{
+    return {
+        formatVersion: 3,
+        category: undefined,
+        type: "single",
+        joke: undefined,
+        flags: {
+            nsfw: false,
+            religious: false,
+            political: false,
+            racist: false,
+            sexist: false,
+            explicit: false,
+        },
+        lang: "en",
+        safe: false,
+    };
+}
+
+/**
+ * Inserts a blank line (or more if `amount` is set)
+ * @param {number} [amount=1]
+ */
+function blankLine(amount = 1)
+{
+    if(typeof amount !== "number" || isNaN(amount))
+        throw new TypeError(`Parameter 'amount' is ${isNaN(amount) ? "NaN" : "not of type number"}`);
+
+    let lfChars = "";
+
+    for(let u = 0; u < amount; u++)
+        lfChars += "\n";
+
+    process.stdout.write(lfChars);
+}

+ 59 - 0
tools/add-token.js

@@ -0,0 +1,59 @@
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+const settings = require("../settings");
+
+try
+{
+    let amount;
+
+    try
+    {
+        amount = parseInt(
+            process.argv.find(arg => arg.match(/^-{0,2}\d+$/))
+            .replace(/[-]/g, "")
+        );
+    }
+    catch(err)
+    {
+        jsl.unused(err);
+        amount = NaN;
+    }
+
+    if(isNaN(amount) || amount < 1)
+        amount = 1;
+    
+    amount = Math.min(amount, 10);
+
+    console.log("\n");
+
+    for(let i = 0; i < amount; i++)
+    {
+        let tok = jsl.generateUUID.custom("xxxxyyyyxxxxyyyy_xxxxyyyyxxxxyyyy_xxxxyyyyxxxxyyyy_xxxxyyyyxxxxyyyy", "0123456789abcdefghijklmnopqrstuvwxyz!?$§%*.~");
+
+        let oldFile = [];
+        if(fs.existsSync(settings.auth.tokenListFile))
+        {
+            let fCont = fs.readFileSync(settings.auth.tokenListFile).toString();
+            if(!jsl.isEmpty(fCont))
+                oldFile = JSON.parse(fCont);
+            else
+                oldFile = [];
+        }
+
+        oldFile.push({
+            token: tok,
+            maxReqs: null // null = default
+        });
+
+        fs.writeFileSync(settings.auth.tokenListFile, JSON.stringify(oldFile, null, 4));
+
+        console.log(`Token ${jsl.colors.fg.green}${tok}${jsl.colors.rst} added to the list of tokens at "${settings.auth.tokenListFile}".`);
+    }
+
+    console.log("\n");
+    return process.exit(0);
+}
+catch(err)
+{
+    return process.exit(1);
+}

+ 227 - 0
tools/cli.js

@@ -0,0 +1,227 @@
+#!/usr/bin/env node
+
+const yargs = require("yargs");
+const importFresh = require("import-fresh");
+const { colors, Errors } = require("svcorelib");
+const { resolve } = require("path");
+const dotenv = require("dotenv");
+
+const settings = require("../settings");
+
+const { exit } = process;
+const col = colors.fg;
+
+
+/** Absolute path to JokeAPI's root directory */
+const rootDir = resolve(__dirname, "../"); // if this file is moved, make sure to change this accordingly
+
+
+//#SECTION run
+
+async function run()
+{    
+    try
+    {
+        // ensure cwd is correct if the binary is called in a global context
+        process.chdir(rootDir);
+
+        dotenv.config();
+
+        const argv = prepareCLI();
+
+
+        /** @type {string|null} */
+        const command = argv && argv._ ? argv._[0] : null;
+
+        let file, action;
+
+        // TODO: (v2.4) remove comments below
+        switch(command)
+        {
+        case "start":
+        case "run":
+            file = "../JokeAPI.js";
+            break;
+        case "submissions":
+        case "sub":
+        case "s":
+            action = "Joke submissions";
+            file = "./submissions.js";
+            break;
+        case "info":
+        case "i":
+            file = "./info.js";
+            break;
+        case "add-joke":
+        case "aj":
+        case "j":
+            action = "Add joke";
+            file = "./add-joke.js";
+            break;
+        case "reassign-ids":
+        case "ri":
+            action = "Reassign IDs";
+            file = "./reassign-ids.js";
+            break;
+        case "add-token":
+        case "at":
+        case "t":
+            action = "Add API token";
+            file = "./add-token.js";
+            break;
+        case "validate-ids":
+        case "vi":
+            action = "Validate IDs";
+            file = "./validate-ids.js";
+            break;
+        case "validate-jokes":
+        case "vj":
+            action = "Validate jokes";
+            file = "./validate-jokes.js";
+            break;
+        case "generate-changelog":
+        case "cl":
+            action = "Generate changelog";
+            file = "./generate-changelog.js";
+            break;
+        // case "stresstest": case "str":
+        //     action = "Stress test";
+        //     file = "./stresstest.js";
+        //     break;
+        case "test":
+            action = "Unit tests";
+            file = "./test.js";
+            break;
+        // case "ip-info": case "ip":
+        //     action = "IP info";
+        //     file = "./ip-info.js";
+        case undefined:
+        case null:
+        case "":
+            console.log(`${settings.info.name} CLI v${settings.info.version}\n`);
+            return yargs.showHelp();
+        default:
+            return warn(`Unrecognized command '${command}'\nUse '${argv.$0} -h' to see a list of commands`);
+        }
+
+        if(!file)
+            throw new Error(`Command '${command}' (${action.toLowerCase()}) didn't yield an executable file`);
+
+        action && console.log(`${settings.info.name} CLI - ${action}`);
+
+        return importFresh(file);
+    }
+    catch(err)
+    {
+        return error(err);
+    }
+}
+
+/**
+ * Prepares the CLI so it can show help
+ * @returns {yargs.Argv<*>}
+ */
+function prepareCLI()
+{
+    //#SECTION general
+    yargs.scriptName("jokeapi")
+        .usage("Usage: $0 <command>")
+        .version(`${settings.info.name} v${settings.info.version} - ${settings.info.projGitHub}`)
+            .alias("v", "version")
+        .help()
+            .alias("h", "help");
+
+    //#SECTION commands
+    // TODO: (v2.4) remove comments below
+    yargs.command([ "start", "run" ], `Starts ${settings.info.name} (equivalent to 'npm start')`);
+
+    yargs.command([ "info", "i" ], `Prints information about ${settings.info.name}, like the /info endpoint`);
+
+    yargs.command([ "submissions", "sub", "s" ], "Goes through all joke submissions, prompting to edit, add or delete them");
+
+    yargs.command([ "add-joke", "aj", "j" ], "Runs an interactive prompt that adds a joke");
+
+    yargs.command([ "reassign-ids", "ri", "r" ], "Goes through each joke file and reassigns IDs to each one, consecutively");
+
+    yargs.command([ "add-token [amount]", "at", "t" ], "Generates one or multiple API tokens to be used to gain unlimited access to the API", cmd => {
+        cmd.positional("amount", {
+            describe: "Specifies the amount of tokens to generate - min is 1, max is 10",
+            type: "number",
+            default: 1
+        });
+
+        // cmd.option("no-copy", {
+        //     alias: "nc",
+        //     describe: "Disables auto-copying the token to the clipboard (if amount = 1)",
+        //     type: "boolean"
+        // });
+    });
+
+    yargs.command([ "validate-ids", "vi" ], "Goes through each joke file and makes sure the IDs are correct (no duplicates or skipped IDs & correct order)");
+
+    yargs.command([ "validate-jokes", "vj" ], "Goes through each joke file and checks the validity of each joke and whether they can all be loaded to memory");
+
+    yargs.command([ "generate-changelog", "cl" ], "Turns the changelog.txt file into a markdown file (changelog.md)", cmd => {
+        cmd.option("generate-json", {
+            alias: "j",
+            describe: "Use this argument to generate a changelog-data.json file in addition to the markdown file",
+            type: "boolean"
+        });
+    });
+
+    // yargs.command([ "ip-info", "ip" ], "Starts a server at '127.0.0.1:8074' that just prints information about each request's IP", cmd => {
+    //     cmd.option("color-cycle", {
+    //         alias: "c",
+    //         describe: "Cycles the color of the output after each request (to make spotting a new request easier)",
+    //         type: "boolean"
+    //     });
+    // });
+
+    // yargs.command([ "stresstest", "str" ], `Sends lots of requests to ${settings.info.name} to stresstest it (requires the API to run in another process on the same machine)`);
+
+    yargs.command("test", `Runs ${settings.info.name}'s unit tests`, cmd => {
+        cmd.option("colorblind", {
+            alias: "c",
+            describe: "Include this argument to replace the colors green with cyan and red with magenta",
+            type: "boolean"
+        });
+    });
+
+    yargs.wrap(Math.min(100, process.stdout.columns));
+
+    return yargs.argv;
+}
+
+
+//#SECTION on execute
+
+try
+{
+    if(!process.stdin.isTTY)
+        throw new Errors.NoStdinError("The process doesn't have an stdin channel to read input from");
+    else
+        run();
+}
+catch(err)
+{
+    return error(err);
+}
+
+/**
+ * @param {Error} err
+ */
+function error(err)
+{
+    console.error(`${col.red}${settings.info.name} CLI - ${err.name}:${col.rst}\n${err.stack}\n`);
+    exit(1);
+}
+
+/**
+ * @param {string} warning
+ * @param {string} [type]
+ */
+function warn(warning, type = "Warning")
+{
+    console.log(`${col.yellow}${settings.info.name} CLI - ${type}:${col.rst}\n${warning}\n`);
+    exit(1);
+}

+ 108 - 0
tools/generate-changelog.js

@@ -0,0 +1,108 @@
+/**
+ * Pass --generate-json to generate JSON data file
+ * @author sahithyandev
+ */
+
+const fs = require("fs-extra");
+const options = {
+    SOURCE_FILE: "changelog.txt",
+    DATA_FILE: "changelog-data.json",
+    OUTPUT_FILE: "changelog.md",
+};
+
+function extractVersionArray(versionLines = []) {
+    const versionsObj = {};
+    let currentVersion = "";
+
+    versionLines.forEach((line) => {
+        if (line.startsWith("[")) {
+            currentVersion = line;
+        } else {
+            let prevItems = versionsObj[currentVersion] || [];
+            versionsObj[currentVersion] = [...prevItems, line.slice(4)];
+        }
+    });
+
+    // versionObj will be in this format
+    // {
+    //  "version-title": [
+    //      "entry-1",
+    //      "entry-2"
+    //  ]
+    // }
+
+    return Object.entries(versionsObj).map(
+        ([versionTitle, versionEntries]) => ({
+            versionTitle,
+            versionEntries,
+        })
+    );
+}
+
+function extractData() {
+    const source = fs.readFileSync(options.SOURCE_FILE, "utf-8");
+    const jsonData = {
+        currentVersion: source.match(/- Version (\d\.\d\.\d) -/)[1],
+        versions: [],
+    };
+
+    let versionData = source
+        .split("\n")
+        .filter((line) => line != "")
+        .slice(4);
+
+    jsonData.versions = extractVersionArray(versionData);
+
+    if (process.argv.includes("--generate-json")) {
+        fs.writeFileSync(options.DATA_FILE, JSON.stringify(jsonData, null, 4));
+    }
+    return jsonData;
+}
+
+function writeMD(
+    data = {
+        currentVersion: "",
+        versions: [],
+    }
+) {
+    let outputLines = [
+        `# JokeAPI Changelog (Version ${data["currentVersion"]})`,
+        "",
+    ];
+
+    data.versions.forEach((versionObj) => {
+        let versionContent = [
+            "<br><br><br>\n\n## " + versionObj.versionTitle,
+            ...versionObj.versionEntries,
+        ];
+
+        outputLines.push(...versionContent, "\n");
+    });
+
+    fs.writeFileSync(
+        options.OUTPUT_FILE,
+        outputLines
+            .join("\n")
+            // convert issue references to links
+            .replace(
+                /issue #(\d{1,})/g,
+                "[issue #$1](https://github.com/Sv443/JokeAPI/issues/$1)"
+            )
+            // convert pull request references to links
+            .replace(
+                /PR #(\d{1,})/g,
+                "[pull request #$1](https://github.com/Sv443/JokeAPI/pull/$1)"
+            )
+        + `<br><br><br>\n\nThis file was auto-generated from the source file at [./${options.SOURCE_FILE}](./${options.SOURCE_FILE})`
+    );
+
+    console.log(`\x1b[32m\x1b[1mGenerated changelog at ./${options.OUTPUT_FILE}\n\x1b[0m`);
+}
+
+function generateMD() {
+    const data = extractData();
+
+    writeMD(data);
+}
+
+generateMD();

+ 175 - 0
tools/info.js

@@ -0,0 +1,175 @@
+const { Errors, colors, allOfType } = require("svcorelib");
+const { join, resolve } = require("path");
+
+const { getEnv, getProp } = require("../src/env");
+const parseJokes = require("../src/parseJokes");
+
+const languages = require("../src/languages");
+const translate = require("../src/translate");
+const settings = require("../settings");
+const { readdir } = require("fs-extra");
+
+const col = colors.fg;
+const { exit } = process;
+
+
+/** @typedef {import("svcorelib").Stringifiable} Stringifiable */
+/** @typedef {import("./types").SubmissionInfoResult} SubmissionInfoResult */
+/** @typedef {import("../src/types/languages").LangCode} LangCode */
+
+
+async function run()
+{
+    try
+    {
+        try
+        {
+            await languages.init();
+
+            await translate.init();
+
+            await parseJokes.init();
+        }
+        catch(err)
+        {
+            console.log(`\n${col.red}Error while initializing:${col.rst}${err instanceof Error ? `${err.message}\n${err.stack}` : `\n${err.toString()}`}\n`);
+            exit(1);
+        }
+
+        /**
+         * Decorates an array value with colors and other stuff
+         * @param {Stringifiable[]} val
+         */
+        const n = val => {
+            const ln = val.length;
+
+            const lhs = `(${ln > 0 ? "" : col.yellow}${val.length}${col.rst})`;
+            const rhs = `${col.green}${val.join(`${col.rst}, ${col.green}`)}${col.rst}`;
+            return `${lhs}:  ${rhs}`;
+        };
+
+        /**
+         * Decorates a value with colors and other stuff
+         * @param {number|string} val
+         */
+        const v = val => {
+            const valCol = typeof val === "number" ? (val > 0 ? col.green : col.yellow) : col.green;
+            const value = Array.isArray(val) && allOfType(val, "string") ? val.join(`${col.rst}, ${valCol}`) : val;
+
+            return `      ${valCol}${value}${col.rst}`;
+        };
+
+
+        const { jokes, subm, http } = await getInfo("submissions");
+
+        /** The lines that get printed to the console to display JokeAPI's info */
+        const lines = [
+            `${col.blue}${settings.info.name}${col.rst} v${settings.info.version} [${getEnv(true)}] - Info`,
+            ``,
+            `${col.blue}Jokes:${col.rst}`,
+            `  Total amount:  ${v(jokes.totalAmt)}`,
+            `  Joke languages ${n(jokes.languages)}`,
+            ``,
+            `${col.blue}Submissions:${col.rst}`,
+            `  Amount:   ${v(subm.amount)}`,
+            `  Languages ${n(subm.languages)}`,
+            ``,
+            `${col.blue}HTTP Server:${col.rst}`,
+            `  Port:    ${v(http.port)}`,
+            `  BaseURL: ${v(http.baseUrl)}`,
+        ];
+
+        process.stdout.write(`\n${lines.join("\n")}\n\n`);
+
+        exit(0);
+    }
+    catch(err)
+    {
+        console.log(`\n${col.red}Error while displaying info:${col.rst}${err instanceof Error ? `${err.message}\n${err.stack}` : `\n${err.toString()}`}\n`);
+        exit(1);
+    }
+}
+
+/**
+ * Returns all information about JokeAPI
+ */
+async function getInfo()
+{
+    const { allJokes } = parseJokes;
+
+    /** @type {LangCode[]} */
+    const jokeLangs = Object.keys(allJokes.getJokeCountPerLang());
+
+    const { submCount, submLangs } = await getSubmissionInfo();
+
+    return {
+        /** Internal jokes */
+        jokes: {
+            totalAmt: allJokes._jokeCount,
+            languages: jokeLangs,
+        },
+        /** Joke submissions */
+        subm: {
+            amount: submCount,
+            languages: submLangs,
+        },
+        /** HTTP server */
+        http: {
+            port: getProp("httpPort"),
+            baseUrl: getProp("baseUrl"),
+        },
+        // ...
+    }
+}
+
+/**
+ * Resolves with some info about the submissions JokeAPI has received
+ * @returns {Promise<SubmissionInfoResult, Error>}
+ */
+function getSubmissionInfo()
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            const submBasePath = resolve(settings.jokes.jokeSubmissionPath);
+
+            const langs = await readdir(submBasePath);
+
+            const submFolders = langs.map(lang => join(submBasePath, lang));
+
+            const submissionFiles = [];
+
+            for await(const folder of submFolders)
+                (await readdir(folder))
+                    .forEach(file => submissionFiles.push(file));
+
+            return res({
+                submCount: submissionFiles.length,
+                submLangs: langs,
+            });
+        }
+        catch(err)
+        {
+            return rej(err);
+        }
+    });
+}
+
+
+//#SECTION on execute
+
+(() => {
+    try
+    {
+        if(!process.stdin.isTTY)
+            throw new Errors.NoStdinError("The process doesn't have an stdin channel to read input from");
+        else
+            run();
+    }
+    catch(err)
+    {
+        console.error(`${col.red}${err.message}${col.rst}\n${err.stack}\n`);
+
+        exit(0);
+    }
+})();

+ 59 - 0
tools/reassign-ids.js

@@ -0,0 +1,59 @@
+// this reassigns all jokes' IDs. Always run this after something changes in the joke's order
+// run this with the command "npm run reassign-ids"
+
+const { resolve, join } = require("path");
+const fs = require("fs-extra");
+const settings = require("../settings");
+
+function reassignIds()
+{
+    try
+    {
+        console.log(`\nReassigning joke IDs in files in path "${settings.jokes.jokesFolderPath}"...`);
+
+        let totalReassignedFiles = 0;
+        let totalReassignedIDs = 0;
+
+        fs.readdirSync(settings.jokes.jokesFolderPath).forEach(fName => {
+            if(fName.startsWith("template"))
+                return;
+
+            totalReassignedFiles++;
+
+            let fPath = resolve(join(settings.jokes.jokesFolderPath, fName));
+            let jokeFileObj = JSON.parse(fs.readFileSync(fPath).toString());
+
+            let initialJokes = jokeFileObj.jokes;
+            let initialInfo = jokeFileObj.info;
+
+            let reassignedJokes = [];
+
+            if(initialInfo.formatVersion != settings.jokes.jokesFormatVersion)
+                initialInfo.formatVersion = settings.jokes.jokesFormatVersion;
+
+            initialJokes.forEach((joke, i) => {
+                let rJoke = joke;
+                rJoke.id = i;
+                reassignedJokes.push(rJoke);
+                totalReassignedIDs++;
+            });
+
+            let doneFile = {
+                info: initialInfo,
+                jokes: reassignedJokes
+            };
+
+            fs.writeFileSync(fPath, JSON.stringify(doneFile, null, 4));
+        });
+
+        console.log(`\x1b[32m\x1b[1mDone reassigning IDs of all ${totalReassignedFiles} files (${totalReassignedIDs} reassigned joke IDs).\n\x1b[0m`);
+        process.exit(0);
+    }
+    catch(err)
+    {
+        console.log(`\n\n\x1b[31m\x1b[1m>> Error while reassigning joke IDs:\n${err}\n\n\x1b[0m`);
+        process.exit(1);
+    }
+}
+
+reassignIds();

+ 66 - 0
tools/reformat.js

@@ -0,0 +1,66 @@
+// this reformats jokes from format v2 to format v3
+// run this with the command "npm run reformat"
+
+const fs = require("fs-extra");
+const isEmpty = require("svjsl").isEmpty;
+
+try
+{
+    console.log(`\nReformatting jokes from file "./data/jokes.json" to new format and putting it in file "./data/jokes_new.json"...`);
+
+    let initialJokes = JSON.parse(fs.readFileSync("./data/jokes.json").toString());
+    let newJokes = [];
+    let id = 0;
+
+    initialJokes.jokes.forEach(joke => {
+        if(joke.type == "single") newJokes.push({
+            category: joke.category,
+            type: "single",
+            joke: joke.joke,
+            flags: {
+                nsfw: isEmpty(joke.flags.nsfw) ? false : true,
+                racist: isEmpty(joke.flags.racist) ? false : true,
+                sexist: isEmpty(joke.flags.sexist) ? false : true,
+                religious: isEmpty(joke.flags.religious) ? false : true,
+                political: isEmpty(joke.flags.political) ? false : true,
+                explicit: isEmpty(joke.flags.explicit) ? false : true
+            },
+            id: id
+        });
+
+        if(joke.type == "twopart") newJokes.push({
+            category: joke.category,
+            type: "twopart",
+            setup: joke.setup,
+            delivery: joke.delivery,
+            flags: {
+                nsfw: isEmpty(joke.flags.nsfw) ? false : true,
+                racist: isEmpty(joke.flags.racist) ? false : true,
+                sexist: isEmpty(joke.flags.sexist) ? false : true,
+                religious: isEmpty(joke.flags.religious) ? false : true,
+                political: isEmpty(joke.flags.political) ? false : true,
+                explicit: isEmpty(joke.flags.explicit) ? false : true
+            },
+            id: id
+        });
+
+        id++;
+    });
+
+    let doneFile = {
+        "info": {
+            "formatVersion": 3
+        },
+        "jokes": newJokes
+    };
+
+    fs.writeFileSync("./data/jokes_new.json", JSON.stringify(doneFile, null, 4));
+
+    console.log(`\x1b[32m\x1b[1mDone reformatting all ${newJokes.length} jokes.\x1b[0m\n`);
+    process.exit(0);
+}
+catch(err)
+{
+    console.log(`\n\n\x1b[31m\x1b[1m>> Error while reformatting jokes:\n${err}\n\n\x1b[0m`);
+    process.exit(1);
+}

+ 813 - 0
tools/submissions.js

@@ -0,0 +1,813 @@
+/**
+ * Enjoy this over-engineered pile of garbage that is actually pretty cool
+ * 
+ * @author Sv443
+ * @since 2.3.2
+ * @ref #340 - https://github.com/Sv443/JokeAPI/issues/340
+ */
+
+const { readdir, readFile, writeFile, copyFile, rm, rmdir } = require("fs-extra");
+const { resolve, join } = require("path");
+const { colors, Errors, reserialize, filesystem, isEmpty } = require("svcorelib");
+const prompt = require("prompts");
+const promiseAllSeq = require("promise-all-sequential");
+
+const languages = require("../src/languages");
+const translate = require("../src/translate");
+const parseJokes = require("../src/parseJokes");
+const { reformatJoke } = require("../src/jokeSubmission");
+
+const settings = require("../settings");
+
+const col = colors.fg;
+const { exit } = process;
+
+
+//#MARKER types & init
+
+/** @typedef {import("./types").AllSubmissions} AllSubmissions */
+/** @typedef {import("./types").Submission} Submission */
+/** @typedef {import("./types").ParsedFileName} ParsedFileName */
+/** @typedef {import("./types").ReadSubmissionsResult} ReadSubmissionsResult */
+/** @typedef {import("./types").LastEditedSubmission} LastEditedSubmission */
+/** @typedef {import("./types").Keypress} Keypress */
+/** @typedef {import("../src/types/jokes").JokeSubmission} JokeSubmission */
+/** @typedef {import("../src/types/jokes").JokeFlags} JokeFlags */
+/** @typedef {import("../src/types/jokes").JokesFile} JokesFile */
+/** @typedef {import("../src/types/languages").LangCode} LangCodes */
+
+
+/** @type {LastEditedSubmission} */
+let lastSubmissionType;
+/** @type {number} */
+let currentSub;
+/** @type {boolean} */
+let lastKeyInvalid = false;
+
+const stats = {
+    /** How many submissions were acted upon */
+    submissionsActAmt: 0,
+    /** How many submissions were saved */
+    savedSubmissions: 0,
+    /** How many submissions were deleted / discarded */
+    deletedSubmissions: 0,
+    /** How many submissions were edited */
+    editedSubmissions: 0,
+};
+
+/**
+ * Entrypoint of this tool
+ */
+async function run()
+{
+    try
+    {
+        await languages.init();
+
+        await translate.init();
+
+        await parseJokes.init();
+    }
+    catch(err)
+    {
+        throw new Error(`Error while initializing dependency modules: ${err}`);
+    }
+
+    lastSubmissionType = undefined;
+    currentSub = 1;
+
+    /** @type {LangCodes} */
+    const langCodes = await getLangCodes();
+    const { submissions, amount } = await readSubmissions(langCodes);
+
+    if(amount < 1)
+    {
+        console.log("\nFound no submissions to go through. Exiting.\n");
+        exit(0);
+    }
+
+    const langCount = Object.keys(submissions).length;
+
+    const { proceed } = await prompt({
+        message: `There are ${amount} submissions of ${langCount} language${langCount > 1 ? "s" : ""}. Go through them now?`,
+        type: "confirm",
+        name: "proceed"
+    });
+
+    if(proceed)
+        return promptSubmissions(submissions);
+    else
+    {
+        console.log("Exiting.");
+        exit(0);
+    }
+}
+
+
+//#MARKER prompts
+
+/**
+ * Goes through all submissions, prompting about what to do with them
+ * @param {AllSubmissions} allSubmissions
+ */
+async function promptSubmissions(allSubmissions)
+{
+    const langs = Object.keys(allSubmissions);
+
+    for await(const lang of langs)
+    {
+        /** @type {Submission[]} */
+        const submissions = allSubmissions[lang];
+
+        /** @type {(() => Promise)[]} */
+        const proms = submissions.map((sub) => (() => actSubmission(sub)));
+
+        await promiseAllSeq(proms);
+
+        const langSubfolderPath = resolve(settings.jokes.jokeSubmissionPath, lang);
+
+        await cleanupDir(langSubfolderPath);
+    }
+
+    return finishPrompts();
+}
+
+//#SECTION act on submission
+
+/**
+ * Prompts the user to act on a submission
+ * @param {Submission} sub
+ * @returns {Promise<void, Error>}
+ */
+function actSubmission(sub)
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            console.clear();
+
+            let lastSubmText = "";
+            switch(lastSubmissionType)
+            {
+            case "accepted_safe":
+                lastSubmText = `(Last submission was accepted ${col.green}safe${col.rst})`;
+                break;
+            case "accepted_unsafe":
+                lastSubmText = `(Last submission was accepted ${col.yellow}unsafe${col.rst})`;
+                break;
+            case "edited":
+                lastSubmText = `(Last submission was ${col.magenta}edited${col.rst})`;
+                break;
+            case "deleted":
+                lastSubmText = `(Last submission was ${col.red}deleted${col.rst})`;
+                break;
+            }
+
+            const last = !lastKeyInvalid ? (lastSubmissionType ? lastSubmText : "") : `${col.red}Invalid key - try again${col.rst}`;
+
+            console.log(`${last}\n\n------------\nLanguage: ${sub.lang}\n------------\n`);
+
+            printSubmission(sub);
+
+            /** @type {null|Submission} The submission to be added to the local jokes */
+            let finalSub = null;
+
+            const key = await getKey(`\n${col.blue}Choose action:${col.rst} Accept ${col.green}[S]${col.rst}afe • Accept ${col.magenta}[U]${col.rst}nsafe • ${col.yellow}[E]${col.rst}dit • ${col.red}[D]${col.rst}elete`);
+
+            let safe = false;
+
+            switch(key.name)
+            {
+            case "s": // add safe
+                safe = true;
+                lastSubmissionType = "accepted_safe";
+                finalSub = reserialize(sub);
+                currentSub++;
+                break;
+            case "u": // add unsafe
+                lastSubmissionType = "accepted_unsafe";
+                finalSub = reserialize(sub);
+                currentSub++;
+                break;
+            case "e": // edit
+                lastSubmissionType = "edited";
+                finalSub = await editSubmission(sub);
+                currentSub++;
+                break;
+            case "d": // delete
+                lastSubmissionType = "deleted";
+                await deleteSubmission(sub);
+                currentSub++;
+                return res();
+            default: // invalid key
+                lastKeyInvalid = true;
+                return await actSubmission(sub);
+            }
+
+            if(finalSub && lastSubmissionType != "edited")
+                finalSub.joke.safe = safe;
+
+            // if not deleted in editSubmission()
+            if(finalSub !== null)
+                await saveSubmission(finalSub);
+
+            return res();
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while choosing action for submission #${currentSub}: ${err}`));
+        }
+    });
+}
+
+//#SECTION edit submission
+
+/**
+ * Gets called to edit a submission
+ * @param {Submission} sub
+ * @returns {Promise<Submission>} Returns the edited submission
+ */
+function editSubmission(sub)
+{
+    return new Promise(async (res, rej) => {
+        /** @type {Submission} */
+        const editedSub = reserialize(sub);
+
+        /** @param {Submission} finalSub */
+        const trySubmit = async (finalSub) => {
+            if(typeof finalSub.joke.lang !== "string")
+                finalSub.joke.lang = finalSub.lang;
+
+            const validateRes = parseJokes.validateSingle(finalSub.joke, finalSub.lang);
+            const allErrors = Array.isArray(validateRes) ? validateRes : [];
+
+            if(typeof finalSub.joke.safe !== "boolean")
+                allErrors.push("Property 'safe' is not of type boolean");
+
+            if(allErrors.length > 0)
+            {
+                console.log(`${col.red}Joke is invalid:${col.rst}`);
+                console.log(`- ${allErrors.join("\n- ")}\n`);
+
+                await getKey("Press any key to try again.");
+
+                return res(editSubmission(finalSub)); // async recursion, who doesn't love it
+            }
+            else
+            {
+                stats.editedSubmissions++;
+                return res(Object.freeze(finalSub));
+            }
+        };
+
+        try
+        {
+            const jokeChoices = sub.joke.type === "single" ? [
+                {
+                    title: `Joke (${editedSub.joke.joke})`,
+                    value: "joke",
+                },
+            ] : [
+                {
+                    title: `Setup (${editedSub.joke.setup})`,
+                    value: "setup",
+                },
+                {
+                    title: `Delivery (${editedSub.joke.delivery})`,
+                    value: "delivery",
+                },
+            ];
+
+            const choices = [
+                {
+                    title: `Category (${editedSub.joke.category})`,
+                    value: "category",
+                },
+                {
+                    title: `Type (${editedSub.joke.type})`,
+                    value: "type",
+                },
+                ...jokeChoices,
+                {
+                    title: `Flags (${extractFlags(editedSub.joke)})`,
+                    value: "flags",
+                },
+                {
+                    title: `Safe (${editedSub.joke.safe})`,
+                    value: "safe",
+                },
+                {
+                    title: `${col.green}[Submit]${col.rst}`,
+                    value: "submit",
+                },
+                {
+                    title: `${col.red}[Delete]${col.rst}`,
+                    value: "delete",
+                },
+            ];
+
+            process.stdout.write("\n");
+            
+            const { editProperty } = await prompt({
+                message: "Edit property",
+                type: "select",
+                name: "editProperty",
+                hint: "- Use arrow-keys. Return to select. Esc or Ctrl+C to submit.",
+                choices,
+            });
+
+            switch(editProperty)
+            {
+            case "category":
+            {
+                const catChoices = settings.jokes.possible.categories.map(cat => ({ title: cat, value: cat }));
+
+                const { category } = await prompt({
+                    type: "select",
+                    message: `Select new category`,
+                    name: "category",
+                    choices: catChoices,
+                    initial: settings.jokes.possible.categories.indexOf("Misc"),
+                });
+
+                editedSub.joke.category = category;
+                break;
+            }
+            case "joke":
+            case "setup":
+            case "delivery":
+                editedSub.joke[editProperty] = (await prompt({
+                    type: "text",
+                    message: `Enter new value for '${editProperty}' property`,
+                    name: "val",
+                    initial: editedSub.joke[editProperty] || "",
+                    validate: (val) => (!isEmpty(val) && val.length >= settings.jokes.submissions.minLength),
+                })).val;
+                break;
+            case "type":
+                editedSub.joke.type = (await prompt({
+                    type: "select",
+                    message: "Select a new joke type",
+                    choices: [
+                        { title: "Single", value: "single" },
+                        { title: "Two Part", value: "twopart" },
+                    ],
+                    name: "type",
+                })).type;
+                break;
+            case "flags":
+            {
+                const flagKeys = Object.keys(editedSub.joke.flags);
+                const flagChoices = [];
+
+                flagKeys.forEach(key => {
+                    flagChoices.push({
+                        title: key,
+                        selected: editedSub.joke.flags[key] === true,
+                    });
+                });
+
+                const { newFlags } = await prompt({
+                    type: "multiselect",
+                    message: "Edit joke flags",
+                    choices: flagChoices,
+                    name: "newFlags",
+                    instructions: false,
+                    hint: "- arrow-keys to move, space to toggle, return to submit",
+                });
+
+                Object.keys(editedSub.joke.flags).forEach(key => {
+                    editedSub.joke.flags[key] = false;
+                });
+
+                newFlags.forEach(setFlagIdx => {
+                    const key = flagKeys[setFlagIdx];
+                    editedSub.joke.flags[key] = true;
+                });
+
+                break;
+            }
+            case "safe":
+                editedSub.joke.safe = (await prompt({
+                    type: "confirm",
+                    message: "Is this joke safe?",
+                    initial: false,
+                    name: "safe",
+                })).safe;
+                break;
+            case "submit":
+                return trySubmit(editedSub);
+            case "delete":
+            {
+                const { del } = await prompt({
+                    type: "confirm",
+                    message: "Delete this submission?",
+                    name: "del",
+                });
+
+                if(del)
+                {
+                    lastSubmissionType = "deleted";
+
+                    await deleteSubmission(sub);
+
+                    return res(null);
+                }
+
+                break;
+            }
+            default:
+                return trySubmit(editedSub);
+            }
+
+            return res(await editSubmission(editedSub));
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while editing submission: ${err}`));
+        }
+    });
+}
+
+/**
+ * Deletes/discards a submission
+ * @param {Submission} sub
+ * @returns {Promise<void, Error>}
+ */
+function deleteSubmission(sub)
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            await rm(sub.path);
+
+            stats.submissionsActAmt++;
+            stats.deletedSubmissions++;
+
+            return res();
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while deleting submission at path '${sub.path}': ${err}`));
+        }
+    });
+}
+
+/**
+ * Cleans up the submission directories if they're empty
+ * @param {string} path Path to the submission language subfolder
+ * @returns {Promise<void, Error>}
+ */
+function cleanupDir(path)
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            const subDirFiles = await readdir(path);
+
+            if(subDirFiles.length === 0)
+                await rmdir(path);
+
+            return res();
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while cleaning up directories: ${err}`));
+        }
+    });
+}
+
+//#SECTION print submission
+
+/**
+ * Prints a submission to the console
+ * @param {Submission} sub
+ */
+function printSubmission(sub)
+{
+    const lines = [
+        `Submission #${currentSub} by ${sub.client}:`,
+        `  Category: ${sub.joke.category}`,
+        `  Type:     ${sub.joke.type}`,
+        `  Flags:    ${extractFlags(sub.joke)}`,
+        ``,
+    ];
+
+    if(sub.joke.type === "single")
+        lines.push(sub.joke.joke);
+    if(sub.joke.type === "twopart")
+    {
+        lines.push(sub.joke.setup);
+        lines.push(sub.joke.delivery);
+    }
+
+    process.stdout.write(`${lines.join("\n")}\n\n`);
+}
+
+/**
+ * Extracts flags of a joke submission, returning a string representation
+ * @param {JokeSubmission} joke
+ * @returns {string} Returns flags delimited with `, ` or "none" if no flags are set
+ */
+function extractFlags(joke)
+{
+    /** @type {JokeFlags[]} */
+    const flags = [];
+
+    Object.keys(joke.flags).forEach(key => {
+        if(joke.flags[key] === true)
+            flags.push(key);
+    });
+
+    return flags.length > 0 ? flags.join(", ") : "none";
+}
+
+/**
+ * Called when all submissions have been gone through
+ */
+function finishPrompts()
+{
+    console.log("\nFinished going through submissions.\n");
+
+    const statLines = [
+        `Stats:`,
+        `  Submissions acted upon: ${stats.submissionsActAmt}`,
+        `  Submissions edited:     ${stats.editedSubmissions}`,
+        `  Submissions deleted:    ${stats.deletedSubmissions}`,
+    ];
+
+    console.log(statLines.join("\n"));
+
+    console.log(`\nExiting.\n`);
+
+    exit(0);
+}
+
+/**
+ * Waits for the user to press a key, then resolves with it
+ * @param {string} [prompt]
+ * @returns {Promise<Keypress, Error>}
+ */
+function getKey(prompt)
+{
+    return new Promise(async (res, rej) => {
+        if(typeof prompt === "string")
+            prompt = isEmpty(prompt) ? null : `${prompt.trimRight()} `;
+
+        try
+        {
+            const onKey = (ch, key) => {
+                if(key && key.ctrl && ["c", "d"].includes(key.name))
+                    process.exit(0);
+
+                process.stdin.pause();
+                process.stdin.removeListener("keypress", onKey);
+
+                process.stdin.setRawMode(false);
+
+                prompt && process.stdout.write("\n");
+
+                return res({
+                    name: key.name || ch || "",
+                    ctrl: key.ctrl || false,
+                    meta: key.meta || false,
+                    shift: key.shift || false,
+                    sequence: key.sequence || undefined,
+                    code: key.code || undefined,
+                });
+            };
+            
+            process.stdin.setRawMode(true);
+            process.stdin.on("keypress", onKey);
+
+            prompt && process.stdout.write(prompt);
+        
+            process.stdin.resume();
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while getting key: ${err}`));
+        }
+    });
+}
+
+
+//#MARKER internal stuff
+
+/**
+ * Reads all possible language codes and resolves with them
+ * @returns {Promise<LangCodes[], Error>}
+ */
+function getLangCodes()
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            const file = await readFile(resolve(settings.languages.langFilePath));
+            const parsed = JSON.parse(file.toString());
+
+            return res(Object.keys(parsed));
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while reading language codes: ${err}`));
+        }
+    });
+}
+
+/**
+ * Reads all submissions and resolves with them
+ * @param {LangCodes} langCodes
+ * @returns {Promise<ReadSubmissionsResult, Error>}
+ */
+function readSubmissions(langCodes)
+{
+    /** @type {AllSubmissions} */
+    const allSubmissions = {};
+
+    return new Promise(async (res, rej) => {
+        try
+        {
+            const folders = await readdir(resolve(settings.jokes.jokeSubmissionPath));
+
+            let amount = 0;
+
+            if(folders.length < 1)
+            {
+                return res({
+                    submissions: [],
+                    amount,
+                });
+            }
+
+            /** @type {Promise<void>[]} */
+            const readPromises = [];
+
+            folders.forEach(langCode => {
+                langCode = langCode.toString();
+
+                if(!langCodes.includes(langCode)) // ignore folders that aren't valid
+                    return;
+
+                readPromises.push(new Promise(async readRes => {
+                    const subm = await getSubmissions(langCode);
+
+                    if(subm.length > 0)
+                        allSubmissions[langCode] = subm;
+
+                    amount += subm.length;
+
+                    return readRes();
+                }));
+            });
+
+            await Promise.all(readPromises);
+
+            return res({
+                submissions: allSubmissions,
+                amount,
+            });
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while reading submissions: ${err}`));
+        }
+    });
+}
+
+/**
+ * Reads all submissions of the specified language
+ * @param {LangCodes} lang 
+ * @returns {Promise<Submission[], Error>}
+ */
+function getSubmissions(lang)
+{
+    return new Promise(async (res, rej) => {
+        /** @type {Submission[]} */
+        const submissions = [];
+
+        try
+        {
+            const submissionsFolder = join(settings.jokes.jokeSubmissionPath, lang);
+            const files = await readdir(submissionsFolder);
+
+            for await(const fileName of files)
+            {
+                const path = resolve(submissionsFolder, fileName);
+
+                const file = await readFile(path);
+                /** @type {JokeSubmission} */
+                const joke = JSON.parse(file);
+
+                const valRes = parseJokes.validateSingle(joke, lang);
+                let errors = null;
+
+                if(Array.isArray(valRes))
+                    errors = valRes;
+
+                const { client, timestamp } = parseFileName(fileName);
+
+                submissions.push({ client, joke, timestamp, errors, lang, path });
+            }
+
+            return res(submissions);
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while reading submissions of language '${lang}': ${err}`));
+        }
+    });
+}
+
+/**
+ * Parses the file name of a submission, returning its information
+ * @param {string} fileName
+ * @returns {Readonly<ParsedFileName>}
+ */
+function parseFileName(fileName)
+{
+    if(fileName.startsWith("submission_"))
+        fileName = fileName.substr(11);
+    if(fileName.endsWith(".json"))
+        fileName = fileName.substr(0, fileName.length - 5);
+
+    // eff8e7ca_0_1634205492859
+
+    const [ client, index, timestamp ] = fileName.split("_");
+
+    return Object.freeze({
+        client,
+        index: parseInt(index),
+        timestamp: parseInt(timestamp),
+    });
+}
+
+/**
+ * Saves a submission to the local jokes
+ * @param {Submission} sub
+ */
+function saveSubmission(sub)
+{
+    return new Promise(async (res, rej) => {
+        try
+        {
+            stats.savedSubmissions++;
+            stats.submissionsActAmt++;
+
+            const { lang } = sub;
+            const joke = reformatJoke(sub.joke);
+
+            const jokeFilePath = join(settings.jokes.jokesFolderPath, `jokes-${lang}.json`);
+            const templatePath = join(settings.jokes.jokesFolderPath, settings.jokes.jokesTemplateFile);
+
+            if(!(await filesystem.exists(jokeFilePath)))
+                await copyFile(templatePath, jokeFilePath);
+
+
+            /** @type {JokesFile} */
+            const currentJokesFile = JSON.parse((await readFile(jokeFilePath)).toString());
+            /** @type {any} */
+            const currentJokes = reserialize(currentJokesFile.jokes);
+
+            const lastId = currentJokes[currentJokes.length - 1].id;
+
+            // ensure props match and strip extraneous props
+            joke.id = lastId + 1;
+            joke.lang && delete joke.lang;
+            joke.formatVersion && delete joke.formatVersion;
+
+            currentJokes.push(joke);
+
+            currentJokesFile.jokes = currentJokes;
+
+
+            await writeFile(jokeFilePath, JSON.stringify(currentJokesFile, undefined, 4));
+
+            await rm(sub.path);
+
+
+            return res();
+        }
+        catch(err)
+        {
+            return rej(new Error(`Error while adding submission: ${err}`));
+        }
+    });
+}
+
+
+//#SECTION on execute
+
+try
+{
+    if(!process.stdin.isTTY)
+        throw new Errors.NoStdinError("The process doesn't have an stdin channel to read input from");
+    else
+        run();
+}
+catch(err)
+{
+    console.error(`${col.red}${err.message}${col.rst}\n${err.stack}\n`);
+
+    exit(1);
+}

+ 159 - 0
tools/test.js

@@ -0,0 +1,159 @@
+/* eslint-disable */ // so that the CI linting process doesn't fail - this will be removed in the final revision
+
+const jsl = require("svjsl");
+const fs = require("fs-extra");
+const cp = require("child_process");
+const requireUncached = require('import-fresh');
+const { resolve, join } = require("path");
+const { XMLHttpRequest } = require("xmlhttprequest");
+
+const debug = require("../src/verboseLogging");
+const settings = require("../settings");
+
+var col = { rst: jsl.colors.rst, ...jsl.colors.fg };
+var runningTests = false;
+
+// const baseURL = `http://127.0.0.1:${settings.httpServer.port}`;
+
+
+function init()
+{
+    // let pingIv;
+
+    // let pingJAPI = () => {
+    //     let xhr = new XMLHttpRequest();
+    //     xhr.open("GET", `${baseURL}/ping`);
+
+    //     xhr.onreadystatechange = () => {
+    //         if(xhr.readyState == 4 && !runningTests)
+    //         {
+    //             if(xhr.status < 300)
+    //             {
+    //                 console.log(`\n\n${col.blue}${settings.info.name} is now running.${col.rst}`);
+    //                 clearInterval(pingIv);
+                    
+    //             }
+    //         }
+    //     };
+
+    //     xhr.send();
+    // };
+
+    // pingIv = setInterval(() => pingJAPI(), settings.tests.initPingInterval);
+    // pingJAPI();
+    console.log(`Trying to run tests...`);
+    runAllTests();
+}
+
+function runAllTests()
+{
+    runningTests = true;
+
+    if(process.argv.includes("--colorblind") || process.argv.includes("-c"))
+    {
+        col.green = jsl.colors.fg.cyan;
+        col.red = jsl.colors.fg.magenta;
+    }
+
+    let success = true;
+    let tests = getAllTests();
+    let testsRun = tests.map(t => t.run());
+
+    console.log(`${col.blue}Running ${tests.length} unit test scripts...${col.rst}`);
+
+    Promise.allSettled(testsRun).then(results => {
+        let allOk = true;
+
+        results.forEach(r => {
+            if(r.status == "rejected")
+                allOk = false;
+        });
+
+        let oneSuccessful = false;
+
+        console.log(`\n\n${col.green}These test scripts were successful:\n${col.rst}`);
+
+        results.forEach(res => {
+            if(res.status != "fulfilled")
+                return;
+
+            oneSuccessful = true;
+            
+            let meta = res.value.meta;
+            console.log(`- ${col.green}[${meta.category}/${col.cyan}${meta.name}${col.green}]${col.rst}`);
+        });
+
+        if(!oneSuccessful)
+            console.log("(none)");
+
+
+
+
+        results.forEach(res => {
+            if(res.status != "rejected")
+                return;
+
+            if(success)
+            {
+                console.error(`\n\n${col.red}These tests were unsuccessful:\n${col.rst}`);
+                success = false;
+            }
+            
+            let meta = res.reason.meta;
+            let errors = res.reason.errors;
+
+            console.log(`${col.red}[${meta.category}/${col.cyan}${meta.name}${col.red}]:${col.rst}`);
+            errors.forEach(e => {
+                console.log(`    - ${e}`);
+            });
+
+            process.stdout.write("\n");
+        });
+        
+        console.log(`\n${!success ? `\n${col.red}^ Some unit tests were not successful ^${col.rst}` : ""}\n`);
+
+
+        process.exit(success ? 0 : 1);
+    }).catch(err => {
+        console.error(`${col.red}Error while running unit tests: ${err}\n\n${col.rst}`);
+        process.exit(1);
+    });
+}
+
+function getAllTests()
+{
+    let allTests = [];
+
+    let testsFolder = resolve(settings.tests.location);
+    let testFiles = fs.readdirSync(testsFolder);
+
+    testFiles.forEach(testFile => {
+        if(testFile == "template.js")
+            return;
+
+        let testPath = join(testsFolder, testFile);
+
+        try
+        {
+            let testScript = requireUncached(testPath); // the normal require sometimes returns outdated files out of the cache so I need to use an external module
+
+            if(typeof testScript.meta == "object" && typeof testScript.run == "function")
+            {
+                allTests.push({
+                    meta: testScript.meta,
+                    run: testScript.run
+                });
+            }
+            else
+                console.error(`Error while reading test script "${testFile}": meta and/or run exports are missing\n(skipping)`);
+        }
+        catch(err)
+        {
+            console.error(`Error while reading test script "${testFile}": ${err}\n(skipping)`);
+        }
+    });
+
+    return allTests;
+}
+
+init();

+ 72 - 0
tools/types.d.ts

@@ -0,0 +1,72 @@
+import { Joke, JokeSubmission } from "../src/types/jokes";
+import { LangCode } from "../src/types/languages";
+
+
+//#MARKER submissions
+
+/**
+ * A single joke submission
+ */
+export interface Submission {
+    /** The submission itself */
+    joke: JokeSubmission & { safe: boolean };
+    /** Unique identification of the client (usually IP hash) */
+    client: string;
+    /** Submission timestamp (Unix-13) */
+    timestamp: number;
+    errors: null | string[];
+    lang: LangCode;
+    /** Absolute path to the joke submission */
+    path: string;
+}
+
+/**
+ * This object contains all submissions
+ */
+export type AllSubmissions = {
+    [key in LangCode]?: Submission;
+};
+// to make "en" a required property:
+// & {
+//     [key in DefaultLangCode]: Submission;
+// };
+
+export interface ParsedFileName {
+    /** Unique identification of the client (usually IP hash) */
+    client: string;
+    timestamp: number;
+    /** Index that gets incremented if a file name is duplicate (default = 0) */
+    index: number;
+}
+
+export interface ReadSubmissionsResult {
+    submissions: AllSubmissions;
+    amount: number;
+}
+
+export type LastEditedSubmission = "accepted_safe" | "accepted_unsafe" | "edited" | "deleted";
+
+export interface Keypress {
+    name: string;
+    ctrl: boolean;
+    meta: boolean;
+    shift: boolean;
+    sequence?: string;
+    code?: string;
+}
+
+//#MARKER add-joke
+
+export type AddJoke = Joke & { formatVersion: number, lang: LangCode, safe: boolean };
+
+export type NullableObj<T> = {
+    [P in keyof T]: (T[P] | null);
+};
+
+//#MARKER info
+
+export interface SubmissionInfoResult
+{
+    submCount: number;
+    submLangs: LangCode[];
+}

+ 87 - 0
tools/validate-ids.js

@@ -0,0 +1,87 @@
+// this validates all jokes' IDs. This will be run through the CI to make sure the IDs are correct
+// run this with the command "npm run reassign-ids"
+
+const { resolve, join } = require("path");
+const fs = require("fs-extra");
+const jsl = require("svjsl");
+const settings = require("../settings");
+
+const col = { ...jsl.colors.fg, rst: jsl.colors.rst };
+
+/**
+ * Exactly what the name suggests
+ * @param {string} msg A short message
+ * @param {string|Error} err The full error string or object
+ */
+function exitWithError(msg, err)
+{
+    console.log(`\n\n\x1b[31m\x1b[1m>> ${msg}:\n${err}\n\n\x1b[0m`);
+    process.exit(1);
+}
+
+try
+{
+    console.log(`\nValidating joke IDs in files in "${settings.jokes.jokesFolderPath}"...`);
+
+    let validatedFiles = 0;
+    let notOk = 0;
+
+    fs.readdirSync(settings.jokes.jokesFolderPath).forEach(fName => {
+        if(fName.startsWith("template"))
+            return;
+
+        let langCode = fName.split("-")[1].substr(0, 2);
+
+        let filePath = resolve(join(settings.jokes.jokesFolderPath, fName));
+
+        let jokeFileObj = JSON.parse(fs.readFileSync(filePath).toString());
+        let initialJokes = jokeFileObj.jokes;
+        let initialInfo = jokeFileObj.info;
+
+        if(initialInfo.formatVersion != settings.jokes.jokesFormatVersion)
+            return exitWithError("Error while checking format version", `Format version in file "${filePath}" (version ${initialInfo.formatVersion}) is different from the one being currently used in JokeAPI (${settings.jokes.jokesFormatVersion})`);
+
+        let erroredJokes = [];
+
+        initialJokes.forEach((joke, i) => {
+            if(joke.id != i)
+                erroredJokes.push({joke: joke, idx: i});
+        });
+
+        validatedFiles++;
+
+        if(erroredJokes.length != 0)
+        {
+            console.log(`\n\n\x1b[31m\x1b[1mInvalid joke ID${erroredJokes.length > 1 ? "s" : ""} found:\x1b[0m\n`);
+            console.log(`Format:  #ID | LangCode | Category | Joke    (error)`);
+            erroredJokes.forEach(errjoke => {
+                let jokeContent = "";
+                if(errjoke.joke.type == "single")
+                    jokeContent = errjoke.joke.joke.replace(/\n/gm, "\\n");
+                else if(errjoke.joke.type == "twopart")
+                    jokeContent = `${errjoke.joke.setup.replace(/\n/gm, "\\n")} -/- ${errjoke.joke.delivery.replace(/\n/gm, "\\n")}`;
+
+                if(jokeContent.length > 40)
+                    jokeContent = `${jokeContent.substr(0, 40)}...`;
+
+                console.log(`#${errjoke.joke.id} | ${langCode} | ${errjoke.joke.category} | ${jokeContent}    ${col.red}(Expected ID #${errjoke.idx} - joke instead has #${errjoke.joke.id})${col.rst}`);
+                notOk++;
+            });
+        }
+    });
+
+    if(notOk > 0)
+    {
+        console.log(`\n\x1b[33m\x1b[1mYou can run the command "npm run reassign-ids" to correct all joke IDs\n\x1b[0m`);
+        process.exit(1);
+    }
+    else
+    {
+        console.log(`\x1b[32m\x1b[1mDone validating IDs of all ${validatedFiles} files.\n\x1b[0m`);
+        process.exit(0);
+    }
+}
+catch(err)
+{
+    return exitWithError("General error while validating joke IDs", err);
+}

+ 18 - 0
tools/validate-jokes.js

@@ -0,0 +1,18 @@
+const jsl = require("svjsl");
+const settings = require("../settings");
+
+
+
+console.log(`\nValidating jokes-xy.json files in "${settings.jokes.jokesFolderPath}"...\n`);
+
+require("../src/languages").init().then(() => {
+    require("../src/translate").init().then(() => {
+        require("../src/parseJokes").init().then(() => {
+            console.log(`${jsl.colors.fg.green}All jokes are valid.${jsl.colors.rst}\n`);
+            process.exit(0);
+        }).catch(err => {
+            console.log(`${jsl.colors.fg.red}${err}${jsl.colors.rst}\n`);
+            process.exit(1);
+        });
+    });
+});