UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,013 lines (926 loc) 27.3 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 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 readline = require('readline') const os = require("os") var argv = require('minimist')(process.argv.slice(2)); const errors = require('./errors') 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 logfile. Use log instead of console.log whenever possible. */ global.log = new adaptor.Log() /** list of game Objects */ var games = {} /** all plugin modules that were required */ var plugin_modules = {} /** Database module depends on selection in config */ var database /** openAPI document object */ var openapi /** * initiate adaptor based on config * * - create login security middleware. * - create var data folder for game data * - provide static route for example website * - connect to Database * - instantiate games that are listed in config * * @param {Object} config - Contains parameters to load existing games * @param {Object} config.database - Database connection parameters * @param {Array} [config.games] - List of games that will be loaded */ 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: "" }) } else { log.warn(0, "No authentication method specified") adaptor.auth = new auth.NoAuth({ login: "Anonymosaurus", password: "" }) } log_socket.use(adaptor.auth.authenticateSocket.bind(adaptor.auth)) adaptor.getPlugin = getPlugin adaptor.getAvailablePlugins = getAvailablePlugins 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')) file.mkdir(path.join(adaptor.data, 'plugins')) 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 adaptor.dereference(path.join(__dirname , '/doc/api/public/openapi.yaml')) }) .then(result => { openapi = result adaptor.openapi = openapi 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 => { 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") logWebhooks() } rl.prompt() }) .catch(error => { log.error(0, error) 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('/config', (req, res) => { res.send(config) }) api.get('/plugins', (req, res) => { res.send(getAvailablePlugins()) }) 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) 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) } } 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) } /** * 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 }) 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') file.mkdir(game_data.data) file.mkdir(path.join(game_data.data, 'functions')) file.mkdir(game_data.files) game_data['name'] = name let game_app = express.Router() app.use(`/${name_convert_spaces}`, game_app) // Host Public game data game_app.use('/', express.static(path.join(__dirname, '/' , game_data.data), { 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 => { if (config_file) { if (!config.hasOwnProperty('games')) { config['games'] = {} } config.games[name] = game_data file.saveJSON(config, config_file) } log.info(0, result.status + " game " + name) return resolve(result) }) .catch(error => { log.error(0, "Unexpected Error while loading game " + name + ":") log.error(0, error) }) }); } /** * 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] 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 } } /** * Games that load a plugin, use this function, so that all plugin modules are only required once. * If another game added the same plugin already already required module is used. * * plugin has to be a folder in the source plugin directory or the custom plugin directory and contain a package.json. * * @callback GetPlugin * @param {string} - plugin name * @returns {Object} new instance of the plugins main class */ function getPlugin(plugin) { if (!plugin_modules.hasOwnProperty(plugin)) { let plugins = this.getAvailablePlugins() let package = plugins.find(p => p.name == plugin) if(!package) { throw new adaptor.NotFoundError("couldn't find " + plugin + " in any of the plugin directories") } if (file.exists(path.join(package.path , package.main))) { plugin_modules[plugin] = require(package.path + '/' + package.main) } else { throw new adaptor.NotFoundError("couldn't find main file for " + plugin + " plugin at " + path.join(package.path, package.main)) } plugin_modules[plugin].package = package log.debug(0, "load plugin module " + plugin + " from " + package.path) } let plugin_object if (plugin_modules[plugin].hasOwnProperty("Plugin")) { plugin_object = new plugin_modules[plugin].Plugin() plugin_object.name = plugin plugin_object.package = plugin_modules[plugin].package } else { plugin_object = plugin_modules[plugin] } return plugin_object } /** * list all plugins found in plugins directory * * @returns {Array<Object>} information dictionary about all plugins available */ function getAvailablePlugins() { let plugin_directories = getPluginDirectories("plugins", true) plugin_directories = plugin_directories.concat(getPluginDirectories(path.join(config.data, "plugins"))) if(config.hasOwnProperty("plugins")) { for(let folder of config.plugins) { plugin_directories = plugin_directories.concat(getPluginDirectories(folder)) } } let plugins = plugin_directories.map(plugin_dir => { if (file.exists(path.join(plugin_dir , 'package.json'))) { let package = require(plugin_dir + '/package.json') package.path = plugin_dir if(!package.package_name) { package.package_name = package.name } Object.assign(package, package.adaptorex) delete package.adaptorex return package } }) return plugins } function getPluginDirectories(folder, is_app_dir) { let dirs = [] if(path.isAbsolute(folder)) { dirs = file.ls(folder, "directories").map(dir => path.join(folder, dir)) } else if (is_app_dir) { dirs = file.ls(path.join(__dirname , folder), "directories").map(dir => path.join(__dirname, folder, dir)) } else { dirs = file.ls(path.join(folder), "directories").map(dir => path.join(process.cwd(), folder, dir)) } return dirs } /** * 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 (argv.hasOwnProperty('c')) { setConfig(argv.c) } else if (argv.hasOwnProperty('config')) { setConfig(argv.config) } else { setConfig(config_file) } function setConfig(filename) { config_file = filename try { config = require(path.join(process.cwd(), filename)) } catch (e) { if (e.code !== 'MODULE_NOT_FOUND') { log.error(0, e) } log.warn(0, 'config file missing. Will create ' + filename) file.mkdir(config.data) } } config['headless'] = false for (let arg in argv) { if (arg == 'headless') { config['headless'] = true } else if (argv[arg] == true) { log.warn(0, "parameters missing for option '" + arg + "'") } else { switch (arg) { case 'level': config['level'] = argv.level 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 '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 } } } 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 authentification 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 } } file.saveJSON(config, config_file) .then(result => { 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 = {} Input.confirmation = function (action, element, callback) { rl.question("are you sure you want to " + action + " " + element + "? (y/n) ", answ => { if (answ == "y" || answ == "yes") { Promise.resolve(callback(element)) .catch(error => { log.error(0, error) }) } rl.prompt() }) } 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 "load": if (input.length == 2) { loadGame(input[1]) } else if (input.length > 2) { loadGame(input[1], { template: input[2] }) } break case "unload": if (input.length > 1) { unloadGame(input[1]) } break case "plugins": log.info(0, "List of available Plugins:\n") for (let t of getAvailablePlugins()) { log.info(0, t.name) } break case "level": if (input.length == 2) { log.setLevel(input[1]) config['level'] = input[1] file.saveJSON(config, config_file) break } log.info(0, "wrong number of arguments for level command") 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() })