DTP Base provides a scalable and secure Node.js application development harness ready for production service.
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.
 
 
 
 

299 lines
9.3 KiB

// dtp-base.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import 'dotenv/config';
import path, { dirname } from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
import * as rfs from 'rotating-file-stream';
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import WEBPACK_CONFIG from './webpack.config.js';
import { SiteTripwire } from './lib/site-tripwire.js';
import { SiteRuntime } from './lib/site-runtime.js';
import http from 'node:http';
import express from 'express';
import morgan from 'morgan';
import session from 'express-session';
import RedisStore from 'connect-redis';
import passport from 'passport';
class SiteWebApp extends SiteRuntime {
static get name ( ) { return 'SiteWebApp'; }
static get slug ( ) { return 'webApp'; }
constructor ( ) {
super(SiteWebApp, __dirname);
}
async start ( ) {
await super.start();
this.log.info('loading SiteTripwire (known-malicious request protection)');
this.tripwire = new SiteTripwire(this);
await this.tripwire.start();
await this.startExpressJS();
}
async shutdown ( ) {
await super.shutdown();
if (this.io) {
await this.io.shutdown();
}
return 0; // exitCode
}
async startExpressJS ( ) {
const { session: sessionService } = this.services;
this.app = express();
this.app.locals.config = this.config;
this.app.locals.pkg = this.config.pkg;
this.app.locals.site = this.config.site;
this.app.set('view engine', 'pug');
this.app.set('views', path.join(__dirname, 'app', 'views'));
this.app.set('x-powered-by', false);
await this.populateViewModel(this.app.locals);
this.app.use(this.tripwire.guard.bind(this.tripwire));
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
const sessionStore = new RedisStore({ client: this.redis });
const cookieDomain = (process.env.NODE_ENV === 'production') ? process.env.DTP_SITE_DOMAIN_KEY : `localhost`;
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
this.sessionOptions = {
name: `dtp.${process.env.DTP_SITE_DOMAIN_KEY}.${process.env.NODE_ENV}`,
secret: process.env.HTTP_SESSION_SECRET,
resave: false,
saveUninitialized: true,
proxy: (process.env.NODE_ENV === 'production') || (process.env.HTTP_SESSION_TRUST_PROXY === 'enabled'),
cookie: {
domain: cookieDomain,
path: '/',
httpOnly: true,
secure: process.env.HTTP_COOKIE_SECURE === 'enabled',
sameSite: process.env.HTTP_COOKIE_SAMESITE || false,
maxAge: SESSION_DURATION,
},
store: sessionStore,
};
this.app.use(session(this.sessionOptions));
/*
* PassportJS setup
*/
this.log.info('initializting PassportJS');
this.app.use(passport.initialize());
this.app.use(passport.session());
/*
* DTP Session Service integration
*/
this.app.use(this.services.session.middleware());
/*
* 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',
});
this.app.use(morgan(process.env.DTP_LOG_HTTP_FORMAT || 'combined', { stream: httpLogStream }));
}
function cacheOneDay (req, res, next) {
res.set('Cache-Control', 'public, maxage=86400, s-maxage=86400, immutable');
return next();
}
function serviceWorkerAllowed (req, res, next) {
res.set('Service-Worker-Allowed', '/');
return next();
}
/*
* Static content and file services
*/
const staticOptions = {
cacheControl: true,
immutable: true,
maxAge: '4h',
dotfiles: 'ignore',
};
this.app.use('/img', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'client', 'img'), staticOptions));
this.app.use('/static', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'client', 'static'), staticOptions));
this.app.use('/dist', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'dist')));
this.app.use('/js', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'client', 'js')));
this.app.use('/lib', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'lib', 'client', 'js')));
this.app.use('/fontawesome', cacheOneDay, express.static(path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free')));
this.app.use('/uikit', cacheOneDay, express.static(path.join(__dirname, 'node_modules', 'uikit')));
this.app.use('/pretty-checkbox', cacheOneDay, express.static(path.join(__dirname, 'node_modules', 'pretty-checkbox', 'dist')));
this.app.use('/cropperjs', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'cropperjs', 'dist')));
this.app.use('/dayjs', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'dayjs')));
this.app.use('/numeral', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'numeral', 'min')));
this.app.use('/highlight.js', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'highlight.js')));
/*
* Webpack integration
*/
if (process.env.NODE_ENV !== 'production') {
this.compiler = webpack(WEBPACK_CONFIG);
this.webpackDevMiddleware = webpackDevMiddleware(this.compiler, {
publicPath: WEBPACK_CONFIG.output.publicPath,
writeToDisk: true,
});
this.app.use(this.webpackDevMiddleware);
}
this.app.use(
(req, res, next) => {
res.locals.dtp = { request: req };
return next();
},
sessionService.middleware(),
);
await this.loadControllers();
/*
* Default error handler
*/
this.log.info('registering ExpressJS error handler');
this.app.use((error, req, res, next) => { // jshint ignore:line
res.locals.errorCode = error.statusCode || error.status || 500;
this.log.error('ExpressJS error', { url: req.url, error });
res.status(res.locals.errorCode).render('error', {
message: error.message,
error,
errorCode: res.locals.errorCode,
});
});
/*
* Create the HTTP server, do not start it.
*/
this.httpServer = http.createServer(this.app);
this.createIoServer();
/*
* Start the HTTP server
*/
return new Promise((resolve, reject) => {
const host = process.env.HTTP_HOST || '127.0.0.1';
const port = parseInt(process.env.HTTP_PORT || '3000', 10);
this.log.info('starting HTTP server', { host, port });
this.httpServer.listen(port, host, (err) => {
if (err) {
this.log.error('failed to start application services', { err });
return reject(new Error('failed to start application services', { cause: err }));
}
this.log.info(`${this.config.pkg.name} application services online.`);
return resolve();
});
});
}
async createIoServer ( ) {
/*
* Create Socket.io server bound to HTTP server
*/
const { SiteIoServer } = await import('./lib/site-ioserver.js');
this.log.info('creating Socket.io server');
this.io = new SiteIoServer(this);
await this.io.start(this.httpServer);
}
async loadControllers ( ) {
const basePath = path.join(this.config.root, 'app', 'controllers');
const entries = await fs.promises.readdir(basePath, { withFileTypes: true });
const inits = [ ];
this.controllers = { };
for await (const entry of entries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith("js")) {
this.log.alert('skipping invalid file in controllers directory', { entry });
continue;
}
try {
const ControllerClass = (await import(path.join(basePath, entry.name))).default;
if (!ControllerClass) {
this.log.error('failed to receive a default export class from controller', { script: entry.name });
throw new Error('controller failed to provide a default export');
}
this.log.info('loading controller', {
script: entry.name,
name: ControllerClass.name,
slug: ControllerClass.slug,
});
const controller = new ControllerClass(this);
this.controllers[ControllerClass.slug] = controller;
inits.push(controller);
} catch (error) {
this.log.error('failed to load controller', { error });
throw new Error('failed to load controller', { cause: error });
}
}
for await (const controller of inits) {
if (controller.slug === 'home') {
continue;
}
await controller.start();
}
/*
* Start the Home controller
*/
await this.controllers.home.start();
}
}
(async ( ) => {
try {
const app = new SiteWebApp();
try {
await app.start();
} catch (error) {
await app.shutdown();
await app.terminate();
throw new Error('failed to start web app', { cause: error });
}
} catch (error) {
console.error('failed to start application harness', error);
}
})();