The DTP Sites web app development engine.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

464 lines
15 KiB

// 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 mongoConnectUri = `mongodb://${process.env.MONGODB_HOST}/${process.env.MONGODB_DATABASE}`;
module.db = await mongoose.connect(mongoConnectUri, {
socketTimeoutMS: 0,
keepAlive: true,
keepAliveInitialDelay: 300000,
dbName: process.env.MONGODB_DATABASE,
});
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 model = require(modelScript);
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);
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);
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(mongoose.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 { page: pageService } = module.services;
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();
}
/*
* Static file services (project)
*/
module.app.use(express.static(path.join(dtp.config.root, 'client')));
module.app.use('/dist', cacheOneDay, 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.dtp = {
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);
}
});
module.app.use(pageService.menuMiddleware.bind(pageService));
/*
* 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') {
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}`);
});
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.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');
};