const fs = require('fs-extra'); const Scope = require('./scope'); const Parser = require('./parser'); const db = require('./db'); const validator = require('../validator'); const config = require('../setup/config'); const debug = require('debug')('server-connect:app'); const debug2 = require('debug')('server-connect:output'); const { clone, formatDate, parseDate } = require('../core/util'); const { toSystemPath } = require('../core/path'); const os = require('os'); if (!global.fileCache) { global.fileCache = new Map(); } if (!global.db) { global.db = {}; } if (!global.rateLimiter) { global.rateLimiter = {}; } function App(req = {}, res = {}) { this.error = false; this.data = {}; this.meta = {}; this.settings = {}; this.modules = {}; this.io = global.io; this.req = req; this.res = res; this.global = new Scope(); this.scope = this.global; this.rateLimiter = {}; this.mail = {}; this.auth = {}; this.oauth = {}; this.db = {}; this.s3 = {}; this.jwt = {}; this.trx = {}; if (config.globals.data) { this.set(config.globals.data); } this.set({ $_ERROR: null, //$_SERVER: process.env, $_ENV: process.env, $_GET: req.query, $_POST: req.body, $_PARAM: req.params, $_HEADER: req.headers, $_COOKIE: req.cookies, $_SESSION: req.session, }); let urlParts = req.originalUrl ? req.originalUrl.split('?') : ['', '']; let $server = { CONTENT_TYPE: req.headers && req.headers['content-type'], HTTPS: req.protocol == 'https', PATH_INFO: req.path, QUERY_STRING: urlParts[1], REMOTE_ADDR: req.ip, REQUEST_PROTOCOL: req.protocol, REQUEST_METHOD: req.method, SERVER_NAME: req.hostname, BASE_URL: req.headers && req.protocol + '://' + req.headers['host'], URL: urlParts[0], HOSTNAME: os.hostname(), }; if (req.headers) { for (let header in req.headers) { $server['HTTP_' + header.toUpperCase().replace(/-/, '_')] = req.headers[header]; } } // Try to keep same as ASP/PHP this.set('$_SERVER', $server); } App.prototype = { set: function (key, value) { this.global.set(key, value); }, get: function (key, def) { let value = this.global.get(key); return value !== undefined ? value : def; }, remove: function (key) { this.global.remove(key); }, setSession: function (key, value) { this.req.session[key] = value; }, getSession: function (key) { return this.req.session[key]; }, removeSession: function (key) { delete this.req.session[key]; }, regenerateSessionId: function () { return new Promise((resolve, reject) => { const oldSession = this.req.session; this.req.session.regenerate((err) => { if (err) return reject(err); for (let key in oldSession) { this.req.session[key] = oldSession[key]; } resolve(); }); }); }, setCookie: function (name, value, opts) { if (!this.res.headersSent) { if (this.res.cookie) { this.res.cookie(name, value, opts); } } else { debug(`Trying to set ${name} cookie while headers were already sent.`); } }, getCookie: function (name, signed) { return signed ? this.req.signedCookies[name] : this.req.cookies[name]; }, removeCookie: function (name, opts) { if (this.res.clearCookie) { // copy all options except expires and maxAge const clearOpts = Object.assign({}, opts); delete clearOpts.expires; delete clearOpts.maxAge; this.res.clearCookie(name, clearOpts); } }, setRateLimiter: function (name, options) { const { RateLimiterMemory, RateLimiterRedis } = require('rate-limiter-flexible'); if (global.redisClient) { global.rateLimiter[name] = new RateLimiterRedis({ ...options, storeClient: global.redisClient, keyPrefix: 'ac:' + name + ':', }); } else { global.rateLimiter[name] = new RateLimiterMemory({ ...options, keyPrefix: 'ac:' + name + ':', }); } return global.rateLimiter[name]; }, getRateLimiter: function (name) { if (global.rateLimiter[name]) { return global.rateLimiter[name]; } if (config.rateLimiter[name]) { return this.setRateLimiter(name, config.rateLimiter[name]); } if (fs.existsSync(`app/modules/RateLimiter/${name}.json`)) { let action = fs.readJSONSync(`app/modules/RateLimiter/${name}.json`); return this.setRateLimiter(name, action.options); } throw new Error(`Couldn't find rate limiter "${name}".`); }, setMailer: function (name, options) { let setup = {}; options = this.parse(options); switch (options.server) { case 'mail': setup.sendmail = true; break; case 'ses': const aws = require('@aws-sdk/client-ses'); const ses = new AWS.SES({ endpoint: options.endpoint, credentials: { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey, }, }); setup.SES = { ses, aws }; break; default: // https://nodemailer.com/smtp/ setup.host = options.host || 'localhost'; setup.port = options.port || 25; setup.secure = options.useSSL || false; setup.auth = { user: options.username, pass: options.password, }; setup.tls = { rejectUnauthorized: false, }; } return (this.mail[name] = setup); }, getMailer: function (name) { if (this.mail[name]) { return this.mail[name]; } if (config.mail[name]) { return this.setMailer(name, config.mail[name]); } if (fs.existsSync(`app/modules/Mailer/${name}.json`)) { let action = fs.readJSONSync(`app/modules/Mailer/${name}.json`); return this.setMailer(name, action.options); } throw new Error(`Couldn't find mailer "${name}".`); }, setAuthProvider: async function (name, options) { const Provider = require('../auth/' + options.provider.toLowerCase()); const provider = new Provider(this, options, name); if (!provider.identity) await provider.autoLogin(); this.set('identity', provider.identity); return (this.auth[name] = provider); }, getAuthProvider: async function (name) { if (this.auth[name]) { return this.auth[name]; } if (config.auth[name]) { return await this.setAuthProvider(name, config.auth[name]); } if (fs.existsSync(`app/modules/SecurityProviders/${name}.json`)) { let action = fs.readJSONSync(`app/modules/SecurityProviders/${name}.json`); return await this.setAuthProvider(name, action.options); } throw new Error(`Couldn't find security provider "${name}".`); }, setOAuthProvider: async function (name, options) { const OAuth2 = require('../oauth'); const services = require('../oauth/services'); options = this.parse(options); let service = this.parseOptional(options.service, 'string', null); let opts = service ? services[service] : {}; opts.client_id = this.parseOptional(options.client_id, 'string', null); opts.client_secret = this.parseOptional(options.client_secret, 'string', null); opts.token_endpoint = opts.token_endpoint || this.parseRequired(options.token_endpoint, 'string', 'oauth.provider: token_endpoint is required.'); opts.auth_endpoint = opts.auth_endpoint || this.parseOptional(options.auth_endpoint, 'string', ''); opts.scope_separator = opts.scope_separator || this.parseOptional(options.scope_separator, 'string', ' '); opts.access_token = this.parseOptional(options.access_token, 'string', null); opts.refresh_token = this.parseOptional(options.refresh_token, 'string', null); opts.jwt_bearer = this.parseOptional(options.jwt_bearer, 'string', false); opts.client_credentials = this.parseOptional(options.client_credentials, 'boolean', false); opts.params = Object.assign({}, opts.params, this.parseOptional(options.params, 'object', {})); this.oauth[name] = new OAuth2(this, this.parse(options), name); await this.oauth[name].init(); return this.oauth[name]; }, getOAuthProvider: async function (name) { if (this.oauth[name]) { return this.oauth[name]; } if (config.oauth[name]) { return await this.setOAuthProvider(name, config.oauth[name]); } if (fs.existsSync(`app/modules/oauth/${name}.json`)) { let action = fs.readJSONSync(`app/modules/oauth/${name}.json`); return await this.setOAuthProvider(name, action.options); } throw new Error(`Couldn't find oauth provider "${name}".`); }, setDbConnection: function (name, options) { if (global.db[name]) { return global.db[name]; } options = this.parse(options); switch (options.client) { case 'couchdb': const { host, port, user, password, database } = options.connection; const nano = require('nano'); global.db[name] = nano( `http://${user ? user + (password ? ':' + password : '') + '@' : ''}${host}${ port ? ':' + port : '' }/${database}` ); global.db[name].client = options.client; return global.db[name]; case 'sqlite3': if (options.connection.filename) { options.connection.filename = toSystemPath(options.connection.filename); } break; case 'mysql': case 'mysql2': options.connection = { supportBigNumbers: true, dateStrings: !!options.tz, decimalNumbers: true, ...options.connection, }; /* options.connection.typeCast = function(field, next) { if (field.type == 'NEWDECIMAL') { return field.string() + 'm'; } return next(); }; */ break; case 'mssql': if (options.tz) { // use local timezone to have same behavior as the other drivers // to prevent problems node should have same timezone as database options.connection.options = { useUTC: false, ...options.connection.options }; } break; case 'postgres': if (options.tz) { const types = require('pg').types; const parseFn = (val) => val; types.setTypeParser(types.builtins.TIME, parseFn); types.setTypeParser(types.builtins.TIMETZ, parseFn); types.setTypeParser(types.builtins.TIMESTAMP, parseFn); types.setTypeParser(types.builtins.TIMESTAMPTZ, parseFn); } break; } if (options.connection && options.connection.ssl) { if (options.connection.ssl.key) { options.connection.ssl.key = fs.readFileSync(toSystemPath(options.connection.ssl.key)); } if (options.connection.ssl.ca) { options.connection.ssl.ca = fs.readFileSync(toSystemPath(options.connection.ssl.ca)); } if (options.connection.ssl.cert) { options.connection.ssl.cert = fs.readFileSync(toSystemPath(options.connection.ssl.cert)); } } options.useNullAsDefault = true; const formatRecord = (record, meta) => { for (column in record) { if (record[column] != null) { if (meta.has(column)) { const info = meta.get(column); if (['json', 'object', 'array'].includes(info.type)) { if (typeof record[column] == 'string') { try { // column of type json returned as string, need parse record[column] = JSON.parse(record[column]); } catch (err) { console.warn(err); } } } if (info.type == 'date') { if (typeof record[column] == 'string' && record[column].startsWith('0000-00-00')) { record[column] = undefined; } else { record[column] = formatDate(record[column], 'yyyy-MM-dd'); } } if (info.type == 'time') { if (options.tz == 'local') { record[column] = formatDate(record[column], 'HH:mm:ss.v'); } else if (options.tz == 'utc') { record[column] = parseDate( parseDate(formatDate('now', 'yyyy-MM-dd ') + formatDate(record[column], 'HH:mm:ss.v')) ) .toISOString() .slice(11); } } if ( ['datetime', 'timestamp'].includes(info.type) || Object.prototype.toString.call(record[column]) == '[object Date]' ) { if (typeof record[column] == 'string' && record[column].startsWith('0000-00-00')) { record[column] = undefined; } else if (options.tz == 'local') { record[column] = formatDate(record[column], 'yyyy-MM-dd HH:mm:ss.v'); } else if (options.tz == 'utc') { record[column] = parseDate(record[column]).toISOString(); } } } else { // try to detect datetime if (Object.prototype.toString.call(record[column]) == '[object Date]') { if (options.tz == 'local') { record[column] = formatDate(record[column], 'yyyy-MM-dd HH:mm:ss.v'); } else if (options.tz == 'utc') { record[column] = parseDate(record[column]).toISOString(); } } else if ( typeof record[column] == 'string' && /^\d{4}-\d{2}-\d{2}([T\s]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}(:\d{2})?)?)?$/.test(record[column]) ) { if (record[column].startsWith('0000-00-00')) { record[column] = undefined; } else if (options.tz == 'local') { record[column] = formatDate(record[column], 'yyyy-MM-dd HH:mm:ss.v'); } else if (options.tz == 'utc') { record[column] = parseDate(record[column]).toISOString(); } } } if (record[column] == undefined) { record[column] = null; } else if (record[column].toJSON) { record[column] = record[column].toJSON(); } } } return record; }; options.postProcessResponse = function (result, queryContext) { const meta = new Map(); if (Array.isArray(queryContext)) { for (let item of queryContext) { if (item.name && item.type) { meta.set(item.name, item); } } } if (Array.isArray(result)) { return result.map((record) => formatRecord(record, meta)); } else { return formatRecord(result, meta); } }; global.db[name] = db(options); return global.db[name]; }, getDbConnection: function (name) { if (config.db[name]) { return this.setDbConnection(name, config.db[name]); } const path = `app/modules/connections/${name}.json`; const action = global.fileCache.get(path) || fs.readJSONSync(`app/modules/connections/${name}.json`); const isDynamic = this.isDynamic(action); const options = clone(action.options); // need to clone because this.parse will update object by ref if (!global.fileCache.has(path)) { // if action was not already cached do it now global.fileCache.set(path, action); } if (isDynamic) { // since it is only used internal json stringify is good enough for creating a unique name name = JSON.stringify(this.parse(options)); } if (this.trx[name]) return this.trx[name]; return this.setDbConnection(name, options); }, setS3Provider: function (name, options) { options = this.parse(options); const { S3 } = require('@aws-sdk/client-s3'); const endpoint = this.parseRequired(options.endpoint, 'string', 's3.provider: endpoint is required.'); const accessKeyId = this.parseRequired(options.accessKeyId, 'string', 's3.provider: accessKeyId is required.'); const secretAccessKey = this.parseRequired( options.secretAccessKey, 'string', 's3.provider: secretAccessKey is required.' ); let region = options.region || 'us-east-1'; let pos = endpoint.indexOf('.amazonaws'); if (pos > 3) region = endpoint.substr(3, pos - 3); let forcePathStyle = options.forcePathStyle || false; this.s3[name] = new S3({ endpoint: 'https://' + endpoint, credentials: { accessKeyId, secretAccessKey }, region, signatureVersion: 'v4', forcePathStyle, }); return this.s3[name]; }, getS3Provider: function (name) { if (this.s3[name]) { return this.s3[name]; } if (config.s3[name]) { return this.setS3Provider(name, config.s3[name]); } if (fs.existsSync(`app/modules/s3/${name}.json`)) { let action = fs.readJSONSync(`app/modules/s3/${name}.json`); return this.setS3Provider(name, action.options); } throw new Error(`Couldn't find S3 provider "${name}".`); }, setJSONWebToken: function (name, options) { const jwt = require('jsonwebtoken'); options = this.parse(options); let opts = {}; if (options.alg) opts.algorithm = options.alg; if (options.iss) opts.issuer = options.iss; if (options.sub) opts.subject = options.sub; if (options.aud) opts.audience = options.aud; if (options.jti) opts.jwtid = options.jti; if (options.expiresIn) opts.expiresIn = options.expiresIn; return (this.jwt[name] = jwt.sign( { ...options.claims, }, options.key, { expiresIn: 3600, // use as default (required by google) ...opts, } )); }, getJSONWebToken: function (name) { if (config.jwt[name]) { return this.setJSONWebToken(name, config.jwt[name]); } if (fs.existsSync(`app/modules/jwt/${name}.json`)) { let action = fs.readJSONSync(`app/modules/jwt/${name}.json`); return this.setJSONWebToken(name, action.options); } throw new Error(`Couldn't find JSON Web Token "${name}".`); }, define: async function (cfg, internal) { if (cfg.settings) { this.settings = clone(cfg.settings); } if (cfg.vars) { this.set(clone(cfg.vars)); } if (cfg.meta) { this.meta = clone(cfg.meta); await validator.init(this, this.meta); } if (fs.existsSync('app/modules/global.json')) { await this.exec(await fs.readJSON('app/modules/global.json'), true); } /* debug('body: %o', this.req.body); debug('query: %o', this.req.query); debug('params: %o', this.req.params); debug('headers: %o', this.req.headers); debug('cookies: %o', this.req.cookies); debug('session: %o', this.req.session); */ await this.exec(cfg.exec || cfg, internal); }, sub: async function (actions, scope) { const subApp = new App(this.req, this.res); subApp.global = this.global; subApp.scope = this.scope.create(scope); await subApp.exec(actions, true); return subApp.data; }, exec: async function (actions, internal) { if (actions.exec) { return this.exec(actions.exec, internal); } actions = clone(actions); await this._exec(actions.steps || actions); if (this.error !== false) { if (actions.catch) { this.scope.set('$_ERROR', this.error.message); this.error = false; await this._exec(actions.catch, true); } else { throw this.error; } } if (!internal && !this.res.headersSent && !this.noOutput) { this.res.json(this.data); } }, _exec: async function (steps, ignoreAbort) { if (this.res.headersSent) return; if (typeof steps == 'string') { return this.exec(await fs.readJSON(`app/modules/${steps}.json`), true); } if (this.res.headersSent) { // do not execute other steps after headers has been sent return; } if (Array.isArray(steps)) { for (let step of steps) { await this._exec(step); if (this.error) return; } return; } if (steps.disabled) { return; } if (steps.action) { try { let module; if (fs.existsSync(`extensions/server_connect/modules/${steps.module}.js`)) { module = require(`../../extensions/server_connect/modules/${steps.module}`); } else if (fs.existsSync(`lib/modules/${steps.module}.js`)) { module = require(`../modules/${steps.module}`); } else { throw new Error(`Module ${steps.module} doesn't exist`); } if (typeof module[steps.action] != 'function') { throw new Error(`Action ${steps.action} doesn't exist in ${steps.module || 'core'}`); } debug(`Executing action step ${steps.action}`); debug(`options: %O`, steps.options); if (!ignoreAbort && config.abortOnDisconnect && this.req.isDisconnected) { throw new Error('Aborted'); } const data = await module[steps.action].call(this, clone(steps.options), steps.name, steps.meta); if (data instanceof Error) { throw data; } debug2(`${steps.name || steps.action}: %O`, data); if (steps.name) { this.scope.set(steps.name, data); if (steps.output) { this.data[steps.name] = data; } } } catch (e) { debug2(`Error at ${steps.name || steps.action}: %O`, e); this.error = e; return; } } }, parse: function (value, scope) { return Parser.parseValue(value, scope || this.scope); }, parseRequired: function (value, type, err) { if (value === undefined) { throw new Error(err); } let val = Parser.parseValue(value, this.scope); if (type == '*') { if (val === undefined) { throw new Error(err); } } else if (type == 'boolean') { val = !!val; } else if (typeof val != type) { throw new Error(err); } return val; }, parseOptional: function (value, type, def) { if (value === undefined) return def; let val = Parser.parseValue(value, this.scope); if (type == '*') { if (val === undefined) val = def; } else if (type == 'boolean') { if (val === undefined) { val = def; } else { val = !!val; } } else if (typeof val != type) { val = def; } return val; }, parseSQL: function (sql) { if (!sql) return null; ['values', 'orders'].forEach((prop) => { if (Array.isArray(sql[prop])) { sql[prop] = sql[prop].filter((value) => { if (!value.condition) return true; return !!Parser.parseValue(value.condition, this.scope); }); } }); if (sql.wheres && sql.wheres.rules) { if (sql.wheres.conditional && !Parser.parseValue(sql.wheres.conditional, this.scope)) { delete sql.wheres; } else { sql.wheres.rules = sql.wheres.rules.filter(function filterConditional(rule) { if (!rule.rules) return true; if (rule.conditional && !Parser.parseValue(rule.conditional, this.scope)) return false; rule.rules = rule.rules.filter(filterConditional, this); return rule.rules.length; }, this); if (!sql.wheres.rules.length) { delete sql.wheres; } } } if (sql.sub) { for (const name in sql.sub) { sql.sub[name] = this.parseSQL(sql.sub[name]); } } return Parser.parseValue(sql, this.scope); }, isDynamic: function (value) { if (typeof value == 'string') { return value.includes('{{'); } if (typeof value == 'object') { for (const prop in value) { if (this.isDynamic(value[prop])) { return true; } } } return false; }, }; module.exports = App;