// 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); /* * 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); /* * 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 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); } })();