// site-platform.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const path = require('path'); const fs = require('fs'); const glob = require('glob'); const express = require('express'); const cookieParser = require('cookie-parser'); const session = require('express-session'); const RedisSessionStore = require('connect-redis')(session); const passport = require('passport'); const mongoose = require('mongoose'); const Redis = require('ioredis'); const ioEmitter = require('socket.io-emitter'); const morgan = require('morgan'); const rfs = require('rotating-file-stream'); const compress = require('compression'); const methodOverride = require('method-override'); const marked = require('marked'); const { SiteAsync } = require(path.join(__dirname, 'site-async')); const { SiteLog } = require(path.join(__dirname, 'site-log')); module.connectDatabase = async (/*dtp*/) => { try { module.log.info('connecting to MongoDB database', { pid: process.pid, host: process.env.MONGODB_HOST, database: process.env.MONGODB_DATABASE, }); const mongoConnectionInfo = { host: process.env.MONGODB_HOST, db: process.env.MONGODB_DATABASE, username: encodeURIComponent(process.env.MONGODB_USERNAME), password: encodeURIComponent(process.env.MONGODB_PASSWORD), options: process.env.MONGODB_OPTIONS || '', }; let mongoConnectUri = `mongodb://${process.env.MONGODB_HOST}/${process.env.MONGODB_DATABASE}`; if (process.env.NODE_ENV === 'production'){ mongoConnectUri = `mongodb://${mongoConnectionInfo.username}:${mongoConnectionInfo.password}@${mongoConnectionInfo.host}/${mongoConnectionInfo.options}`; } module.db = await mongoose.connect(mongoConnectUri, { socketTimeoutMS: 0, keepAlive: true, keepAliveInitialDelay: 300000, dbName: mongoConnectionInfo.db, }); module.log.info('connected to MongoDB'); } catch (error) { module.log.error('failed to connect to database', { error }); throw error; } }; module.loadModels = async (dtp) => { dtp.models = module.models = [ ]; const modelScripts = glob.sync(path.join(dtp.config.root, 'app', 'models', '*.js')); modelScripts.forEach((modelScript) => { const instance = require(modelScript); const model = instance(module.db); if (module.models[model.modelName]) { module.log.error('model name collision', { name: model.modelName }); process.exit(-1); } module.models.push(model); }); }; module.exports.resetIndexes = async (dtp) => { await SiteAsync.each(dtp.models, module.resetIndex); }; module.resetIndex = async (model) => { return new Promise(async (resolve, reject) => { module.log.info('dropping model indexes', { model: model.modelName }); model.collection.dropIndexes((err) => { if (err) { return reject(err); } module.log.info('creating model indexes', { model: model.modelName }); model.ensureIndexes((err) => { if (err) { return reject(err); } return resolve(model); }); }); }); }; module.connectRedis = async (dtp) => { try { const options = { host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT || '6379', 10), password: process.env.REDIS_PASSWORD, keyPrefix: process.env.REDIS_KEY_PREFIX, lazyConnect: false, }; module.log.info('connecting to Redis', { host: options.host, port: options.port, prefix: options.keyPrefix, }); module.redis = dtp.redis = new Redis(options); module.redis.setMaxListeners(64); // prevents warnings/errors with Bull Queue module.ioEmitter = dtp.ioEmitter = ioEmitter(module.redis); module.log.info('Redis connected'); } catch (error) { module.log.error('failed to connect to Redis', error); throw error; } }; module.getRedisKeys = (pattern) => { return new Promise((resolve, reject) => { return module.redis.keys(pattern, (err, response) => { if (err) { return reject(err); } return resolve(response); }); }); }; module.loadServices = async (dtp) => { dtp.services = module.services = { }; const scripts = glob.sync(path.join(dtp.config.root, 'app', 'services', '*.js')); const inits = [ ]; await SiteAsync.each(scripts, async (script) => { const service = await require(script); module.services[service.name] = service.create(dtp); module.services[service.name].__dtp_service_name = service.name; inits.push(module.services[service.name]); }); await SiteAsync.each(inits, async (service) => { await service.start(); }); }; module.loadControllers = async (dtp) => { const scripts = glob.sync(path.join(dtp.config.root, 'app', 'controllers', '*.js')); const inits = [ ]; dtp.controllers = { }; await SiteAsync.each(scripts, async (script) => { const controller = await require(script); controller.instance = await controller.create(dtp); module.log.info('controller loaded', { name: controller.name, slug: controller.slug }); dtp.controllers[controller.name] = controller; inits.push(controller); }); await SiteAsync.each(inits, async (controller) => { if (controller.isHome) { return; // must run last } await controller.instance.start(); }); /* * Start the Home controller */ await dtp.controllers.home.instance.start(); /* * Default error handler */ module.log.info('registering ExpressJS error handler'); dtp.app.use((error, req, res, next) => { // jshint ignore:line res.locals.errorCode = error.statusCode || error.status || 500; module.log.error('ExpressJS error', { url: req.url, error }); res.status(res.locals.errorCode).render('error', { message: error.message, error, errorCode: res.locals.errorCode, }); // return next(error); }); }; module.exports.startPlatform = async (dtp) => { try { module.log = new SiteLog(module, dtp.config.component); if (process.env.NODE_ENV === 'local') { module.log.alert('allowing self-signed certificates for host-to-host communications', { env: process.env.NODE_ENV }); process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } dtp.config.jobQueues = require(path.join(dtp.config.root, 'config', 'job-queues')); await module.connectDatabase(dtp); await module.connectRedis(dtp); await module.loadModels(dtp); SiteLog.setModel(module.db.model('Log')); await module.loadServices(dtp); module.log.info(`Digital Telepresence Platform v${dtp.pkg.version} started for [${dtp.pkg.name}]`); } catch (error) { module.log.error('platform failed to start', { error }); return; } }; module.exports.startWebServer = async (dtp) => { const IS_PRODUCTION = (process.env.NODE_ENV === 'production'); dtp.app = module.app = express(); module.app.set('views', path.join(dtp.config.root, 'app', 'views')); module.app.set('view engine', 'pug'); module.app.set('x-powered-by', false); /* * Expose useful modules and information */ module.app.locals.DTP_SCRIPT_DEBUG = (process.env.NODE_ENV === 'local'); module.app.locals.env = process.env; module.app.locals.dtp = dtp; module.app.locals.pkg = require(path.join(dtp.config.root, 'package.json')); module.app.locals.mongoose = require('mongoose'); module.app.locals.moment = require('moment'); module.app.locals.numeral = require('numeral'); module.app.locals.phoneNumberJS = require('libphonenumber-js'); module.app.locals.anchorme = require('anchorme').default; module.app.locals.hljs = require('highlight.js'); /* * Set up the protected markdown renderer that will refuse to process links and images * for security reasons. */ var markedRenderer = new marked.Renderer(); markedRenderer.link = (href, title, text) => { return text; }; markedRenderer.image = (href, title, text) => { return text; }; marked.setOptions({ renderer: markedRenderer }); module.app.locals.marked = marked; /* * HTTP request logging */ if (process.env.DTP_LOG_FILE === 'enabled') { const httpLogStream = rfs.createStream(process.env.DTP_LOG_FILE_NAME_HTTP || 'dtp-sites-access.log', { interval: '1d', path: process.env.DTP_LOG_FILE_PATH || '/tmp', compress: 'gzip', }); module.app.use(morgan(process.env.DTP_LOG_HTTP_FORMAT || 'combined', { stream: httpLogStream })); } function cacheOneDay (req, res, next) { res.set('Cache-Control', 's-maxage=86400'); return next(); } function serviceWorkerAllowed (req, res, next) { res.set('Service-Worker-Allowed', '/'); return next(); } /* * Static file services (project) */ module.app.use(express.static(path.join(dtp.config.root, 'client'))); module.app.use('/dist', cacheOneDay, serviceWorkerAllowed, express.static(path.join(dtp.config.root, 'dist'))); module.app.use('/img', cacheOneDay, express.static(path.join(dtp.config.root, 'client', 'img'))); /* * Static file services (vendor) */ module.app.use('/uikit/images', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'src', 'images'))); module.app.use('/uikit', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'dist'))); module.app.use('/chart.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chart.js', 'dist'))); module.app.use('/chartjs-adapter-moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chartjs-adapter-moment', 'dist'))); module.app.use('/pretty-checkbox', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'pretty-checkbox', 'dist'))); module.app.use('/fontawesome', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', '@fortawesome', 'fontawesome-free'))); module.app.use('/moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'moment', 'min'))); module.app.use('/numeral', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'numeral', 'min'))); module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); module.app.use('/tinymce', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'tinymce'))); module.app.use('/highlight.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'highlight.js'))); module.app.use('/mpegts', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'mpegts.js', 'dist'))); /* * ExpressJS middleware */ module.app.use(express.json({ })); module.app.use(express.urlencoded({ extended: true })); module.app.use(cookieParser()); module.app.use(compress()); module.app.use(methodOverride()); /* * Express sessions */ module.log.info('initializing redis session store'); var sessionStore = new RedisSessionStore({ client: module.redis }); const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days module.sessionConfig = { name: `dtp:${process.env.DTP_SITE_DOMAIN_KEY}.${process.env.NODE_ENV}`, secret: process.env.HTTP_SESSION_SECRET, resave: true, proxy: IS_PRODUCTION || (process.env.HTTP_SESSION_TRUST_PROXY === 'enabled'), saveUninitialized: true, cookie: { domain: process.env.DTP_SITE_DOMAIN_KEY, path: '/', httpOnly: true, secure: true, sameSite: process.env.HTTP_COOKIE_SAMESITE || false, expires: SESSION_DURATION, }, store: sessionStore, }; module.log.info('configuring session handler', { domain: module.sessionConfig.cookie.domain, httpOnly: module.sessionConfig.cookie.httpOnly, secure: module.sessionConfig.cookie.secure, sameSite: module.sessionConfig.cookie.sameSite, expires: module.sessionConfig.cookie.expires, }); if (module.sessionConfig.proxy) { module.log.info('session will be trusting first proxy'); module.app.set('trust proxy', true); module.sessionConfig.cookie.secure = true; } module.app.use(session(module.sessionConfig)); /* * PassportJS setup */ module.log.info('initializting PassportJS'); module.app.use(passport.initialize()); module.app.use(passport.session()); module.services.oauth2.registerPassport(); module.app.use(module.services.session.middleware()); module.app.use(module.services.userNotification.middleware({ withNotifications: false })); /* * Application logic middleware */ module.app.use(async (req, res, next) => { const { cache: cacheService } = dtp.services; try { res.locals.request = req; const settingsKey = `settings:${dtp.config.site.domainKey}:site`; res.locals.site = Object.assign({ }, dtp.config.site); const settings = await cacheService.getObject(settingsKey); if (settings) { res.locals.site = Object.assign(res.locals.site, settings); } return next(); } catch (error) { module.log.error('failed to populate general request data', { error }); return next(error); } }); /* * Call out to application to register their custom middleware at the right * point in the processing chain. */ module.log.debug('typeof dtp.config.registerMiddleware', { type: (typeof dtp.config.registerMiddleware) }); if (dtp.config && (typeof dtp.config.registerMiddleware === 'function')) { module.log.info('registering custom application middleware'); await dtp.config.registerMiddleware(dtp, module.app); } /* * System Init */ try { dtp.services.oauth2.attachRoutes(module.app); await module.loadControllers(dtp); } catch (error) { module.log.error('failed to initialize application controller', { error }); return; } module.app.use(async (err, req, res, next) => { // jshint ignore:line var errorCode = err.status || err.statusCode || err.code || 500; module.log.error('HTTP error', { error: err }); res.status(errorCode).render('error', { message: err.message, error: err, title: 'error' }); }); if (process.env.HTTP_ENABLE === 'enabled') { if (process.env.HTTP_REDIRECT_SSL === 'enabled') { await module.createSslRedirectApp(dtp); await module.createHttpServer(dtp, module.redirectApp); } else { await module.createHttpServer(dtp, module.app); } } if (process.env.HTTPS_ENABLE === 'enabled') { await module.createHttpsServer(dtp, module.app); } // prefer to attach Socket.io to the HTTPS server and fall back to HTTP await module.createSocketServer(dtp, module.https || module.http); if (module.http) { await module.startHttpServer(dtp, module.http, dtp.config.http); } if (module.https) { await module.startHttpServer(dtp, module.https, dtp.config.https); } module.log.info(`${dtp.config.component.name} platform online`, { http: dtp.config.http.port, https: dtp.config.https.port, }); }; module.createHttpServer = async (dtp, app) => { module.log.info('creating HTTP server'); module.http = require('http').createServer(app); }; module.createHttpsServer = async (dtp, app) => { const httpsOptions = { cert: await fs.promises.readFile( process.env.HTTPS_SSL_CRT || path.join(dtp.config.root, 'ssl', 'dtp-webapp.crt') ), key: await fs.promises.readFile( process.env.HTTPS_SSL_KEY || path.join(dtp.config.root, 'ssl', 'dtp-webapp.key') ), }; module.log.info('creating HTTPS server'); module.https = require('https').createServer(httpsOptions, app); return module.https; }; module.createSslRedirectApp = async (/* dtp */) => { module.log.info('creating HTTP SSL redirect app'); module.redirectApp = express(); module.redirectApp.use((req, res) => { module.log.info('redirecting to SSL', { host: req.host, url: req.url }); res.redirect(`https://${process.env.DTP_SITE_DOMAIN}${req.url}`); }); return module.redirectApp; }; module.startHttpServer = async (dtp, server, config) => { return new Promise((resolve, reject) => { module.log.info('starting HTTP server', { port: config.port, bind: config.address }); server.listen(config.port, config.address, (err) => { if (err) { return reject(err); } return resolve(); }); }); }; module.createSocketServer = async (dtp, http) => { module.log.info('creating Socket.io server'); const { SiteIoServer } = require(path.join(__dirname, 'site-ioserver')); module.io = new SiteIoServer(dtp); await module.io.start(http); return module.io; }; module.exports.shutdown = async ( ) => { module.log.info('platform shutting down'); };