UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,441 lines (1,339 loc) 39.5 kB
#!/usr/bin/env node /** * creates framework for starting, creating, loading, editing sessions. * * Uses minimist to parse optional arguments * * Express API server with json parse. * * @requires mongo * @requires ne * @requires game * @requires file * @requires plugin_management * * @requires express * @requires http * @requires socket.io * @requires path * @requires readline * @requires os * * @requires minimist * * @module index * @copyright Lasse Marburg 2021 * @license MIT */ require("./adaptor.js") const mongo = require("./mongo.js") const ne = require("./ne.js") const game = require("./game.js") const file = require("./file.js") const auth = require("./authentication.js") const plugin_management = require("./plugin_management.js") const readline = require("readline") const os = require("os") const { MongoError } = require("mongodb") var argv = require("minimist")(process.argv.slice(2)) const semver = require("semver") const errors = require("./errors") const opener = require("opener") const cors = require("cors") const path = require("path") const express = require("express") var app = express() var server = require("http").createServer(app) io = require("socket.io")(server, { cors: { origin: isWhitelisted, methods: ["GET", "POST"], credentials: true } }) // socket.io will not handle BigInt (used by telegram plugin for IDs) well, since serialization fails without the lines below (see also: https://github.com/GoogleChromeLabs/jsbi/issues/30) BigInt.prototype["toJSON"] = function () { return this.toString() } /** @type {express.Router} */ var api = express.Router() Object.assign(adaptor, errors) /** * global logging Object with coloring and log file. Use log instead of console.log whenever possible. */ global.log = new adaptor.Log() /** list of game Objects */ var games = {} /** * @type {plugin_management.PluginManager} * manages plugin install and provision * */ var plugin_manager /** Database module depends on selection in config */ var database /** openAPI document object */ let openapi /** asyncAPI document object */ let asyncapi /** * @typedef {Object} Config * @property {string} host - The host URL for the server * @property {number} port - The port number for the server * @property {string} data - Path to the data directory * @property {Object} database - Database configuration * @property {string} database.type - Type of database (e.g., "nedb", "mongodb") * @property {string} [database.url] - URL for MongoDB connection * @property {string} level - Logging level * @property {string} authentication - Authentication method ("disabled", "basic", etc.) * @property {boolean} headless - Whether to run in headless mode * @property {boolean} [open] - Whether to automatically open the editor in browser * @property {string} [url] - Public URL for the server * @property {Array<string>} [whitelist] - List of allowed origins for CORS * @property {Object} [login] - Login credentials configuration * @property {string} [login.user] - Username for webview login * @property {string} [login.password] - Password for webview login * @property {Array<string>} [plugins] - List of plugins to load * @property {string} [minNpmPluginVersion] - Minimum required version for npm plugins * @property {Object} [games] - Configuration for loaded games */ /** * initiate adaptor based on config * * - create login security middleware. * - create var data folder for game data * - listen for file changes in plugin development folder * - provide static route for example website * - connect to Database * - instantiate games that are listed in config * * @param {Config} config - Contains parameters that customize the setup and load existing games */ function init(config) { let log_socket = io.of(/\/log\/\w+$/) log.createSocket(log_socket) Object.assign(adaptor, config) if (config.authentication == "basic") { adaptor.auth = new auth.BasicAuth(config.users) } else if (config.authentication == "disabled") { adaptor.auth = new auth.NoAuth({ login: "Anonymosaurus", password: "", role: "admin" }) } else { log.warn(0, "No authentication method specified") adaptor.auth = new auth.NoAuth({ login: "Anonymosaurus", password: "", role: "admin" }) } log_socket.use(adaptor.auth.authenticateSocket.bind(adaptor.auth)) plugin_manager = new plugin_management.PluginManager(config) file.watchDir(plugin_manager.development_dir, (e, changed_file) => { if (e !== "change") return const parts = changed_file.split(path.sep) const dev_index = parts.indexOf("development") const plugin_dir = parts[dev_index + 1] const plugin_name = plugin_manager.findPluginNameByDirectory(plugin_dir) log.debug(0, `Change in plugins development folder: ${e} ${changed_file}.`) reloadPlugin(plugin_name).catch((error) => { log.error(0, error) if (error.cause) log.error(0, error.cause) }) }) adaptor.loadPlugin = plugin_manager.loadPlugin.bind(plugin_manager) adaptor.getAvailablePlugins = plugin_manager.getAvailablePlugins.bind(plugin_manager) adaptor.now = function () { return new Date(Date.now()) } adaptor.userInfo = () => { return os.userInfo() } if (!adaptor.python) { if (process.platform == "win32") { adaptor.python = "python" } else { adaptor.python = "python3" } } adaptor.io = io file.mkdir(path.join(adaptor.data, "games")) app.use(cors({ credentials: true, origin: isWhitelisted })) app.use(express.json()) server.listen(config.port) server.on("error", (e) => { if (e.code === "EADDRINUSE") { log.error( "http server", "Port " + config.port + " is already in use. Close any other adaptor instances or other server that uses the same port and restart." ) process.exit(0) } }) if (!file.exists(path.join(__dirname, "/public/index.html"))) { config.headless = true } adaptor.webhooks.push({ name: "local", title: "Local", url: config.host + ":" + config.port }) let ips = adaptor.getIPs() for (let ip in ips) { let ip_name = ip .replace(/[^a-zA-Z0-9 ]/g, "") .replace(" ", "_") .toLowerCase() adaptor.webhooks.push({ name: ip_name, title: ip, url: `http://${ips[ip]}:${config.port}` }) } if (config.url) { adaptor.webhooks.push({ name: "remote", title: "Remote", url: config.url }) } server.on("listening", (res) => { log.info(0, "Server is listening") }) let homesocket = io.of("/") homesocket.on("connection", client) // Init Database if (config.hasOwnProperty("database")) { if (config.database["type"] == "mongodb") { database = mongo } else if (config.database["type"] == "nedb") { database = ne } else { log.error( 0, "Not a valid database type: " + config.database["type"] + "\n. Options: mongodb, nedb " ) process.exit(0) } } else { database = ne } database .start(config.database) .then((result) => { return setLatestVersion() }) .then((result) => { return adaptor.getAvailablePlugins() }) .then((result) => { log.debug(0, `Found ${result.length} available plugins`) return adaptor.dereference( path.join(__dirname, "/doc/api/public/openapi.yaml") ) }) .then((result) => { openapi = result adaptor.openapi = openapi return adaptor.dereference( path.join(__dirname, "/doc/api/public/asyncapi.yaml") ) }) .then((result) => { asyncapi = result adaptor.asyncapi = asyncapi let load_game_promises = [] if (config.hasOwnProperty("games")) { for (let game in config.games) { load_game_promises.push(loadGame(game, config.games[game])) } return Promise.all(load_game_promises) } return }) .then((result) => { if (semver.gt(adaptor.info.latestVersion, adaptor.info.version)) { adaptor.info.updateAvailable = true log.info( 0, `adaptor:ex server up and running. Startup time was ${process.uptime()} sec.` ) log.info( 0, `✨ Update available. ${adaptor.info.version}${adaptor.info.latestVersion} (https://docs.adaptorex.org/install)` ) } else { log.info( 0, `adaptor:ex server version ${adaptor.info.version} up and running. Startup time was ${process.uptime()} sec.` ) } let issues = [] let errors = [] if (Array.isArray(result)) { result.forEach((res) => { if (res.issues.length) { issues = issues.concat(res.issues) } if (res.errors.length) { errors = errors.concat(res.errors) } }) } if (issues.length) { log.warn( 0, `Experienced ${issues.length} issue(s) on startup. See previous error messages.` ) } if (errors.length) { log.error( 0, `Experienced ${errors.length} unexpected error(s) on startup. See previous error messages.` ) } if (config.headless) { log.info(0, "Access adaptor:ex API at one of the following urls") logWebhooks("/api") } else { log.info( 0, "Open adaptor:ex editor with one of the following links or type: open" ) logWebhooks() } if (config.open) { openEditor() } rl.prompt() }) .catch((error) => { log.error(0, error) if (error.cause) { error.message = error.message + "\n" + error.cause.message log.error(0, error.cause) } process.exit(0) }) // add API routing api.use(adaptor.auth.authenticate.bind(adaptor.auth)) api.use((req, res, next) => { log.trace( "API", `${req.method} request to ${req.originalUrl} from ${req.user.login}` ) next() }) api.get("/info", (req, res) => { res.send(adaptor.info) }) api.get("/config", (req, res) => { res.send(config) }) api.get("/me", (req, res) => { try { let user = getMe(req, config) res.send(user) } catch (error) { errorResponseHandling(error, res) } }) api.get("/plugins", (req, res) => { plugin_manager .getAvailablePlugins() .then((result) => { res.send(result) }) .catch((err) => { errorResponseHandling(err, res) }) }) api.get("/games", (req, res) => { getGames() .then((result) => { res.send(result) }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) }) api.post("/game", (req, res) => { if (typeof req.body.name === "string") { if (req.body.template) { var data = { template: req.body.template } } else { var data = undefined } loadGame(req.body.name, data) .then(function (result) { res.status(201).send(result) }) .catch(function (err) { log.error("API", err) if (err.cause) { log.error("API", err.cause) err.message = err.message + "\n" + err.cause.message } res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { log.error("API", "couldn't load game. Name property missing.") res .status(400) .send({ name: "InvalidError", message: "Name property missing." }) } }) api.post("/game/:game/setup", (req, res) => { if (games[req.params.game]) { games[req.params.game] .editSetup(req.body) .then(function (result) { res.status(200).send(result) }) .catch(function (err) { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) api.post("/game/:game/_event", (req, res) => { if (games[req.params.game]) { if (!req.body.name) { log.error("API", "couldn't dispatch game event. Name property missing.") res .status(400) .send({ name: "InvalidError", message: "name property missing." }) return } try { games[req.params.game].event.emit(req.body.name, req.body.payload) log.debug( req.params.game, `dispatch game event '${req.body.name}' via API` ) res.status(200).send({}) } catch (error) { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) } } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) api.get("/game/:game", (req, res) => { if (games[req.params.game]) { games[req.params.game] .getElements() .then((result) => { res.send(result) }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) api.get("/game/:game/collections", (req, res) => { if (games[req.params.game]) { games[req.params.game] .getDataCollectionSchemas() .then((result) => { res.send(result) }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) api.get("/game/:game/media", (req, res) => { if (games[req.params.game]) { Promise.resolve(games[req.params.game].getFiles()) .then((result) => { res.send(result) }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) api.delete("/game/:game", (req, res) => { if (typeof req.params.game === "string") { deleteGame(req.params.game) .then((result) => { if (result) { res.status(204).send() } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { log.error("API", "couldn't delete game. Name property missing.") res .status(400) .send({ name: "ValidationError", message: "Name property missing." }) } }) api.get("/game/:game/actions", (req, res) => { if (games[req.params.game]) { games[req.params.game] .getActions(req.query) .then((result) => { res.send(result) }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) api.get("/game/:game/level_schema", (req, res) => { if (games[req.params.game]) { games[req.params.game] .getLevelSchema() .then((result) => { res.send(result) }) .catch((err) => { log.error("API", err) res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) }) } else { res.status(404).send({ name: "NotFoundError", message: `game ${req.params.game} was not found` }) } }) app.use("/api", api) api.use((err, req, res, next) => { if (err instanceof SyntaxError) { log.error("API", `${req.method} request to ${req.originalUrl} failed`) log.error("API", err.message) return res.status(400).send({ name: err.name, message: err.message }) } log.error("API", err.message) next(err) }) if (!config.headless) { var client_app = express.Router() client_app.use("/", express.static(path.join(__dirname, "/public"))) // Host static webpage for local client build client_app.get("/", function (req, res) { res.sendFile(path.join(__dirname, "public", "index.html")) }) client_app.get("/game/*", function (req, res) { res.sendFile(path.join(__dirname, "public", "index.html")) }) app.use("/", client_app) } } /** * Fetches the latest version of the adaptorex package from the npm registry * and updates the adaptor.info.latestVersion property. * * @async * @function setLatestVersion * @returns {Promise<void>} A promise that resolves when the version check is complete */ async function setLatestVersion() { try { const result = await fetch(`https://registry.npmjs.org/adaptorex`) if (!result.ok) { log.info( 0, `Could not fetch update info from npm. Registry returned ${result.status}: ${result.statusText}` ) } const pckg_data = await result.json() adaptor.info.latestVersion = pckg_data["dist-tags"].latest } catch (error) { log.info(0, `Failed to fetch update info from npm. ${error.message}`) } } function logWebhooks(append_path) { for (let webhook of adaptor.webhooks) { log.info(0, `${webhook.title}:\t${webhook.url}${append_path || ""}`) } } /** * check whitelist to see if cross origin access is allowed for origin url * * @param {*} origin * @param {*} callback */ function isWhitelisted(origin, callback) { if (typeof origin === "undefined") { callback(null, true) } else if (origin.indexOf("//localhost") !== -1) { callback(null, true) } else if (config["whitelist"]) { var originIsWhitelisted = config.whitelist.indexOf(origin) !== -1 if (!originIsWhitelisted) { log.warn( 0, `Rejected request from remote ${origin} because it is not in whitelist.` ) } callback(null, originIsWhitelisted) } else { callback(null, true) } } function client(socket) { log.debug(0, "client connected on " + socket.handshake.headers.host) } /** * Make error specific API response for AdaptorErrors. Otherwise, respond with 500 internal server error. * * @param {Error} err - error object that was caught * @param {Object} res - request response object * @returns undefined */ function errorResponseHandling(err, res) { log.error("API", err) if (err.cause) { err.message = err.message + "\n" + err.cause.message log.error("API", err.cause.message) } if (err instanceof adaptor.NotFoundError) { return res.status(404).send({ name: err.name, message: err.message }) } else if (err instanceof adaptor.AdaptorError) { return res.status(400).send({ name: err.name, message: err.message }) } else if ( (err instanceof MongoError && err.code == 11000) || err.errorType == "uniqueViolated" ) { return res .status(400) .send({ name: "DuplicateError", message: err.message }) } res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) } /** * Open editor client in default web browser */ async function openEditor() { if (!config.headless) { log.info( 0, `Opening adaptor:ex editor at ${config.host}:${config.port} in default web browser` ) opener(`${config.host}:${config.port}`) } else { log.warn( 0, `Can not auto open editor when running in headless mode. Omit the --headless option and make sure you have a built of the adaptor:ex client in public directory.` ) } } function getMe(req, config) { let username if (req.user && req.user.login) { username = req.user.login } const user = adaptor.auth.getUser(config, username) return user } /** * get preview data for all loaded games * * @returns {Promise} list of game preview Objects `{name:"gamename",setup:{}}` */ async function getGames() { let returnmsg = [] for (let game in games) { let game_setup = await games[game].getSetup() returnmsg.push({ name: game, setup: game_setup }) } return returnmsg } /** * - create Game Object with provided Database Object * - resume if there is an existing database * else build default game * - open route to Game editing Website * - note game in config file * * @param {string} name - name of game to be created or continued * @param {Object} [game_data] - game specific config parameters or template name if its a new game */ function loadGame(name, game_data) { if (!game_data) { game_data = {} } return new Promise(function (resolve, reject) { let name_convert_spaces = name.replace(/ /g, "%20") var game_db = new database.Database({ name: name_convert_spaces }) let load_result game_db .connect() .then((result) => { let client = adaptor.io.of(`/game/${name_convert_spaces}`) client.use(adaptor.auth.authenticateSocket.bind(adaptor.auth)) if (config.hasOwnProperty("url")) { game_data["files_url"] = config.url + "/" + name_convert_spaces + "/files" game_data["url"] = config.url + "/" + name_convert_spaces } else { game_data["files_url"] = config.host + ":" + config.port + "/" + name_convert_spaces + "/files" game_data["url"] = config.host + ":" + config.port + "/" + name_convert_spaces } game_data["data"] = path.join( adaptor.data, "games", name_convert_spaces ) game_data["files"] = path.join( adaptor.data, "games", name_convert_spaces, "files" ) game_data["public"] = path.join( adaptor.data, "games", name_convert_spaces, "public" ) file.mkdir(game_data.data) file.mkdir(path.join(game_data.data, "functions")) file.mkdir(game_data.files) file.mkdir(game_data.public) game_data["name"] = name let game_app = express.Router() app.use(`/${name_convert_spaces}`, game_app) // Host Public game files game_app.use( "/files", express.static(game_data.public, { maxAge: 7200000 }) ) let game_api = express.Router() api.use(`/game/${name_convert_spaces}`, game_api) let newgame = new game.Game(game_data, game_app, game_api, client) games[name] = newgame if (result == "created") { if (game_data.hasOwnProperty("template")) { return newgame.build(game_db, game_data.template) } else { return newgame.build(game_db, "basic") } } else if (result == "connected") { return newgame.resume(game_db) } else { throw new Error(name + " could neither be resumed or created.") } }) .then((result) => { load_result = result if (config_file) { if (!config.hasOwnProperty("games")) { config["games"] = {} } config.games[name] = game_data return file.saveJSON(config, config_file) } return }) .then((result) => { log.info(0, load_result.status + " game " + name) return resolve(load_result) }) .catch((error) => { let err = new Error( `Error while loading game ${name}. To skip loading the game, remove it from config file.`, { cause: error } ) return reject(err) }) }) } /** * remove game but keep its data in database */ async function unloadGame(name) { if (games.hasOwnProperty(name)) { if (config_file) { log.info("unload", config.games[name]) if (config.games.hasOwnProperty(name)) { delete config.games[name] await file.saveJSON(config, config_file) } } await games[name].unload() delete games[name] log.info("game", name + " unloaded") return true } else { log.error("unload", "No such Game '" + name + "'") return false } } /** * unload and remove game and its Database * does not create backup! Handle with care */ async function deleteGame(name) { if (games.hasOwnProperty(name)) { await games[name].db.clone("archive_" + name) await games[name].delete() await unloadGame(name) log.info("delete", name + " was successfully deleted") return true } else { log.error("delete", "No such Game '" + name + "'") return false } } /** * Reload a plugin by removing its module and reloading it in all games. * * Allows to update a plugin without having to restart the server. * * @param {string} plugin_name - name of the plugin to reload */ async function reloadPlugin(plugin_name) { await plugin_manager.removePluginModule(plugin_name) await Promise.all( Object.values(games).map((game) => game.topics.plugin.refresh(plugin_name)) ) } function setConfig(filename) { config_file = filename try { config = require(path.resolve(filename)) log.info(0, `Load config file ${filename}`) } catch (e) { if (e.code !== "MODULE_NOT_FOUND") { log.error(0, e) } log.info(0, "Config file missing. Will create " + filename) file.mkdir(path.dirname(filename)) } } /** * Configure login credentials for the webview based on commandline options. * * @param {Object} argv - Command line arguments object * @param {string} [argv.login] - Username for webview login (use "off" to disable login) * @param {string} [argv.password] - Password for webview login * @param {string} [argv.pw] - Alternative password parameter */ function loginConfig() { if (!config.hasOwnProperty("login")) { config["login"] = {} log.info(0, "Create Webview login credentials") } else { log.info(0, "Change Webview login credentials") } if (argv.hasOwnProperty("login")) { if (argv.login == "off") { delete config.login["user"] delete config.login["password"] log.info( 0, "Webview login deactivated. Reactivate by setting login and password." ) return } config.login["user"] = argv.login } if (argv.hasOwnProperty("password")) { config.login["password"] = argv.password } if (argv.hasOwnProperty("pw")) { config.login["password"] = argv.pw } } /** * set url for database access (only mongodb) * -d mongodb -m mongodb://<username>:<password>@localhost:<port>?replicaSet=adaptor-repl * with authentication enabled use: * -d mongodb -m mongodb://<username>:<password>@localhost:<port>/admin?replicaSet=adaptor-repl */ function databaseConfig(db_type) { if (!config.hasOwnProperty("database")) { config["database"] = { type: db_type } } else { config.database["type"] = db_type } if (argv.hasOwnProperty("m")) { config.database["url"] = argv.m } else if (argv.hasOwnProperty("mongodburl")) { config.database["url"] = argv.mongodburl } else if (argv.hasOwnProperty("mongourl")) { config.database["url"] = argv.mongourl } } /** * give all games and plugins the chance to tidy up if necessary then exit node process * */ function quit() { let promises = [] for (let g in games) { promises.push(games[g].quit()) } Promise.all(promises) .then((result) => { log.info("", "Auf bald!") process.exit(0) }) .catch((err) => { log.error("", err) process.exit(0) }) } /******************************************************************************************** * load config and check options * *********************************************************************************************/ /** path to and name of config file. If file is not found config will be created based on options * @default */ var config_file = "./adaptorex/config.json" /** content of config file. * @default */ var config = { host: "http://localhost", port: 8081, data: "./adaptorex", database: { type: "nedb" }, level: "info", authentication: "disabled" } /** * If run as package, set config file and data path to OS specific app directory and always open editor in browser. */ if (process.pkg) { const homeDir = os.homedir() /** * path to the adaptorex app directory * Defaults to OS specific home dir */ var appDir = path.join(homeDir, "adaptorex") switch (os.platform()) { case "win32": // Windows appDir = path.join( process.env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"), "adaptorex" ) break case "darwin": // macOS appDir = path.join(homeDir, "Library", "Application Support", "adaptorex") break case "linux": // Linux appDir = path.join(homeDir, ".local", "share", "adaptorex") break } config_file = `${appDir}/config.json` config["data"] = appDir config["open"] = true } if (argv.hasOwnProperty("c")) { setConfig(argv.c) } else if (argv.hasOwnProperty("config")) { setConfig(argv.config) } else { setConfig(config_file) } config["headless"] = false for (let arg in argv) { if (arg == "headless") { config["headless"] = true } else if (arg == "open" || arg == "o") { config["open"] = true } else if (argv[arg] == true) { log.warn(0, "parameters missing for option '" + arg + "'") } else { switch (arg) { case "level": case "log": config["level"] = argv.level || argv.log break case "l": config["level"] = argv.l break case "p": config.port = argv.p break case "port": config.port = argv.port break case "host": config["host"] = argv.host break case "h": config["host"] = argv.h break case "u": config["url"] = argv.u break case "url": config["url"] = argv.url break case "a": config["authentication"] = argv.a break case "auth": config["authentication"] = argv.auth break case "f": config["data"] = argv.f break case "data": config["data"] = argv.data break case "files": config["data"] = argv.files break case "plugins": config["plugins"] = [argv.plugins] break case "v": config["minNpmPluginVersion"] = argv.v break case "min-npm-plugin-version": config["minNpmPluginVersion"] = argv["min-npm-plugin-version"] break case "d": databaseConfig(argv.d) break case "db": databaseConfig(argv.db) break case "database": databaseConfig(argv.database) break case "python": config["python"] = argv.python case "login": loginConfig() break case "password": loginConfig() break case "pw": loginConfig() break case "_": break } } } file .saveJSON(config, config_file) .then((result) => { log.info(0, `Data directory is at ${config.data}`) file.mkdir(config.data) return log.init(config.level, path.join(config.data, "/log")) }) .then((result) => { init(config) }) .catch((error) => { log.error("config", error) }) /** global object that provides some useful input functions */ global.Input = {} /** * Confirmation function that prompts the user for confirmation before executing a callback function. * @param {string} action - The action being performed. * @param {string} element - The element being affected. * @param {function} callback - The function to be executed if the user confirms. * @returns {Promise} - Resolves when the user confirms or rejects the action. */ Input.confirmation = function (action, element, callback) { return new Promise((resolve, reject) => { rl.question( "are you sure you want to " + action + " " + element + "? (y/n) ", (answ) => { if (answ == "y" || answ == "yes") { callback(element) .then((res) => resolve(res)) .catch((error) => { log.error(0, error.message) }) } else { resolve(`cancelled ${action} ${element}`) } rl.prompt() } ) }) } /** * Takes a date as argument and calculates the time that has passed since and returns it in a human readable way * @param {Date} date - The date to calculate the time difference from * @returns {string} - A human readable string representing the time that has passed since the given date */ Input.since = function (date) { if (!date) { return "never" } const units = [ { name: "year", seconds: 31536000 }, { name: "month", seconds: 2628000 }, { name: "day", seconds: 86400 }, { name: "hour", seconds: 3600 }, { name: "minute", seconds: 60 } ] let timePassed = (Date.now() - date) / 1000 for (const unit of units) { const value = Math.floor(timePassed / unit.seconds) if (value > 0) { return value + " " + unit.name + (value > 1 ? "s " : " ") + "ago" } } return "just now" } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "> " }) rl.on("line", (input) => { if (!input) { return } /* * split input by space character but ignore spaces in quotes (" or ') * * regex for only double quotes: /(?:[^\s"]+|"[^"]*")+/g * source: * https://stackoverflow.com/questions/16261635/javascript-split-string-by-space-but-ignore-space-in-quotes-notice-not-to-spli/16261693#16261693 * * remove quotes consequentially */ input = input.match(/(?:[^\s"']+|['"][^'"]*["'])+/g) for (var i = 0; i < input.length; i++) { input[i] = input[i].replace(/["']+/g, "") } switch (input[0]) { case "quit": quit() break case "games": for (let game in games) { log.info(0, game) } case "del": case "delete": if (input.length > 1) { Input.confirmation("delete", input[1], deleteGame) } break case "open": case "editor": case "browser": openEditor() break case "load": if (input.length == 2) { loadGame(input[1]).catch((error) => { log.error(0, error) if (error.cause) log.error(0, error.cause) }) } else if (input.length > 2) { loadGame(input[1], { template: input[2] }).catch((error) => { log.error(0, error) if (error.cause) log.error(0, error.cause) }) } break case "unload": if (input.length > 1) { unloadGame(input[1]) } break case "plugins": if (input.length <= 2) { plugin_manager.listPlugins().catch((error) => { log.error(0, error) if (error.cause) log.error(0, error.cause) }) break } else if (input.length <= 4) { switch (input[1]) { case "install": plugin_manager .findAvailablePlugin("name", input[2]) .then((plugin) => { if (!plugin) { log.error(0, `Plugin "${input[2]}" not found`) return } return plugin_manager.installPlugin(plugin, input[3]) }) .then(() => { log.info(0, "Install done") }) .catch((error) => { log.error(0, error) if (error.cause) log.error(0, error.cause) }) break case "uninstall": Input.confirmation( "uninstall", input[2], plugin_manager.uninstallPlugin.bind(plugin_manager) ) break case "reload": reloadPlugin(input[2]) .then(() => { log.info(0, `Reload plugin ${input[2]} finished`) }) .catch((error) => { log.error(0, error) if (error.cause) log.error(0, error.cause) }) break } } break case "log": if (input.length == 2) { log.setLevel(input[1]) config["level"] = input[1] file.saveJSON(config, config_file) break } log.info( 0, "Please provide a log level: trace, debug, info, warn, error, silent" ) break case "urls": logWebhooks() break case "api": logWebhooks("/api") break case "whitelist": if (input.length == 3) { if (input[1] == "add") { if (!config["whitelist"]) { config["whitelist"] = [] } config["whitelist"].push(input[2]) } else if (input[1] == "remove") { if (config["whitelist"] && config["whitelist"].includes(input[2])) { config["whitelist"] = config["whitelist"].filter( (w) => w !== input[2] ) } else { log.error( 0, `Can not remove ${input[2]}. Not found in config.whitelist` ) break } } file.saveJSON(config, config_file) break } else if (input.length == 2) { if (input[1] == "any") { delete config["whitelist"] } else if (input[1] == "none") { config["whitelist"] = [] } file.saveJSON(config, config_file) break } else if (input.length == 1) { log.info(0, config["whitelist"]) break } log.info(0, "wrong number of arguments for whitelist command") break default: if (games.hasOwnProperty(input[0])) { let game = input.shift() games[game].command(input) } else { if (Object.keys(games).length == 1) { for (let game in games) { games[game].command(input) } } else { log.info("", "No such game or command '" + input[0] + "'") } } break } rl.prompt() }).on("close", () => { quit() })