UNPKG

@reldens/server-utils

Version:
517 lines (483 loc) 16.9 kB
/** * * Reldens - AppServerFactory * */ const { FileHandler } = require('./file-handler'); const { DevelopmentModeDetector } = require('./app-server-factory/development-mode-detector'); const { ProtocolEnforcer } = require('./app-server-factory/protocol-enforcer'); const { SecurityConfigurer } = require('./app-server-factory/security-configurer'); const { CorsConfigurer } = require('./app-server-factory/cors-configurer'); const { RateLimitConfigurer } = require('./app-server-factory/rate-limit-configurer'); const http = require('http'); const https = require('https'); const express = require('express'); const bodyParser = require('body-parser'); const session = require('express-session'); const compression = require('compression'); class AppServerFactory { constructor() { this.applicationFramework = express; this.bodyParser = bodyParser; this.session = session; this.compression = compression; this.appServer = false; this.app = express(); this.useCors = true; this.useExpressJson = true; this.useUrlencoded = true; this.encoding = 'utf-8'; this.useHttps = false; this.passphrase = ''; this.httpsChain = ''; this.keyPath = ''; this.certPath = ''; this.trustedProxy = ''; this.windowMs = 60000; this.maxRequests = 30; this.applyKeyGenerator = false; this.jsonLimit = '1mb'; this.urlencodedLimit = '1mb'; this.useHelmet = true; this.helmetConfig = false; this.useXssProtection = true; this.globalRateLimit = 0; this.corsOrigin = '*'; this.corsMethods = []; this.corsHeaders = []; this.tooManyRequestsMessage = 'Too many requests, please try again later.'; this.error = {}; this.processErrorResponse = false; this.port = 3000; this.autoListen = false; this.domains = []; this.useVirtualHosts = false; this.defaultDomain = ''; this.maxRequestSize = '10mb'; this.sanitizeOptions = {allowedTags: [], allowedAttributes: {}}; this.staticOptions = { maxAge: '1d', etag: true, lastModified: true, index: false, setHeaders: function(res){ res.set('X-Content-Type-Options', 'nosniff'); res.set('X-Frame-Options', 'DENY'); } }; this.isDevelopmentMode = false; this.developmentDomains = []; this.domainMapping = {}; this.enforceProtocol = true; this.developmentPatterns = []; this.developmentEnvironments = []; this.developmentPorts = []; this.developmentMultiplier = 10; this.developmentExternalDomains = {}; this.developmentModeDetector = new DevelopmentModeDetector(); this.protocolEnforcer = new ProtocolEnforcer(); this.securityConfigurer = new SecurityConfigurer(); this.corsConfigurer = new CorsConfigurer(); this.rateLimitConfigurer = new RateLimitConfigurer(); this.useCompression = true; this.compressionOptions = { level: 6, threshold: 1024, filter: function(req, res){ if(req.headers['x-no-compression']){ return false; } return compression.filter(req, res); } }; } createAppServer(appServerConfig) { if(appServerConfig){ Object.assign(this, appServerConfig); } this.addHttpDomainsAsDevelopment(); this.detectDevelopmentMode(); this.setupDevelopmentConfiguration(); this.setupProtocolEnforcement(); this.setupSecurity(); this.setupCompression(); this.setupVirtualHosts(); this.setupCors(); this.setupRateLimiting(); this.setupRequestParsing(); this.setupTrustedProxy(); this.appServer = this.createServer(); if(!this.appServer){ this.error = {message: 'Failed to create app server'}; return false; } if(this.autoListen){ this.listen(); } return {app: this.app, appServer: this.appServer}; } extractDomainFromHttpUrl(url) { if(!url || !url.startsWith('http://')){ return false; } return url.replace(/^http:\/\//, '').split(':')[0]; } addHttpDomainsAsDevelopment() { let hostDomain = this.extractDomainFromHttpUrl(process.env.RELDENS_APP_HOST); let publicDomain = this.extractDomainFromHttpUrl(process.env.RELDENS_PUBLIC_URL); if(hostDomain && !this.developmentDomains.includes(hostDomain)){ this.developmentDomains.push(hostDomain); } if(publicDomain && !this.developmentDomains.includes(publicDomain)){ this.developmentDomains.push(publicDomain); } } detectDevelopmentMode() { let detectConfig = { developmentDomains: this.developmentDomains, domains: this.domains }; if(0 < this.developmentPatterns.length){ detectConfig['developmentPatterns'] = this.developmentPatterns; } if(0 < this.developmentEnvironments.length){ detectConfig['developmentEnvironments'] = this.developmentEnvironments; } this.isDevelopmentMode = this.developmentModeDetector.detect(detectConfig); } setupDevelopmentConfiguration() { if(!this.isDevelopmentMode){ return; } this.staticOptions.setHeaders = (res) => { res.set('X-Content-Type-Options', 'nosniff'); res.set('X-Frame-Options', 'SAMEORIGIN'); res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); }; } setupProtocolEnforcement() { this.protocolEnforcer.setup(this.app, { isDevelopmentMode: this.isDevelopmentMode, useHttps: this.useHttps, enforceProtocol: this.enforceProtocol }); } setupSecurity() { this.securityConfigurer.setupHelmet(this.app, { isDevelopmentMode: this.isDevelopmentMode, useHelmet: this.useHelmet, helmetConfig: this.helmetConfig, developmentExternalDomains: this.developmentExternalDomains }); this.securityConfigurer.setupXssProtection(this.app, { useXssProtection: this.useXssProtection, sanitizeOptions: this.sanitizeOptions }); } setupCompression() { if(!this.useCompression){ return; } this.app.use(this.compression(this.compressionOptions)); } setupCors() { let corsConfig = { isDevelopmentMode: this.isDevelopmentMode, useCors: this.useCors, corsOrigin: this.corsOrigin }; if(0 < this.corsMethods.length){ corsConfig['corsMethods'] = this.corsMethods; } if(0 < this.corsHeaders.length){ corsConfig['corsHeaders'] = this.corsHeaders; } if(0 < this.developmentPorts.length){ corsConfig['developmentPorts'] = this.developmentPorts; } if( 'object' === typeof this.domainMapping && null !== this.domainMapping && 0 < Object.keys(this.domainMapping) ){ corsConfig['domainMapping'] = this.domainMapping; } this.corsConfigurer.setup(this.app, corsConfig); } setupRateLimiting() { this.rateLimitConfigurer.setup(this.app, { isDevelopmentMode: this.isDevelopmentMode, globalRateLimit: this.globalRateLimit, windowMs: this.windowMs, maxRequests: this.maxRequests, developmentMultiplier: this.developmentMultiplier, applyKeyGenerator: this.applyKeyGenerator, tooManyRequestsMessage: this.tooManyRequestsMessage }); } setupRequestParsing() { if(this.maxRequestSize){ this.jsonLimit = this.maxRequestSize; this.urlencodedLimit = this.maxRequestSize; } if(this.useExpressJson){ this.app.use(this.applicationFramework.json({ limit: this.jsonLimit, verify: this.verifyContentTypeJson.bind(this) })); } if(this.useUrlencoded){ this.app.use(this.bodyParser.urlencoded({ extended: true, limit: this.urlencodedLimit })); } } setupTrustedProxy() { if('' !== this.trustedProxy){ this.app.set('trust proxy', this.trustedProxy); } } verifyContentTypeJson(req, res, buf) { let contentType = req.headers['content-type'] || ''; if( 'POST' === req.method && 0 < buf.length && !contentType.includes('application/json') && !contentType.includes('multipart/form-data') ){ this.error = {message: 'Invalid content-type for JSON request'}; return false; } } setupVirtualHosts() { if(!this.useVirtualHosts || 0 === this.domains.length){ return; } this.app.use((req, res, next) => { let hostname = req.get('host'); if(!hostname){ if(this.defaultDomain){ req.domain = this.defaultDomain; return next(); } this.error = {message: 'No hostname provided and no default domain configured'}; return res.status(400).send('Bad Request'); } let domain = this.findDomainConfig(hostname); if(!domain){ if(this.defaultDomain){ req.domain = this.defaultDomain; return next(); } this.error = {message: 'Unknown domain: ' + hostname}; return res.status(404).send('Domain not found'); } req.domain = domain; next(); }); } findDomainConfig(hostname) { if(!hostname || 'string' !== typeof hostname){ return false; } let cleanHostname = hostname.toLowerCase().trim(); let hostWithoutPort = cleanHostname.split(':')[0]; for(let i = 0; i < this.domains.length; i++){ let domain = this.domains[i]; if(domain.hostname === hostWithoutPort){ return domain; } if(domain.aliases && domain.aliases.includes(hostWithoutPort)){ return domain; } } return false; } createServer() { if(!this.useHttps){ return http.createServer(this.app); } if(this.useVirtualHosts && 0 < this.domains.length){ return this.createHttpsServerWithSNI(); } return this.createSingleHttpsServer(); } createSingleHttpsServer() { let key = FileHandler.readFile(this.keyPath, 'Key'); if(!key){ this.error = {message: 'Could not read SSL key file: ' + this.keyPath}; return false; } let cert = FileHandler.readFile(this.certPath, 'Cert'); if(!cert){ this.error = {message: 'Could not read SSL certificate file: ' + this.certPath}; return false; } let credentials = {key, cert, passphrase: this.passphrase}; if('' !== this.httpsChain){ let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority'); if(ca){ credentials.ca = ca; } } return https.createServer(credentials, this.app); } createHttpsServerWithSNI() { let defaultCredentials = this.loadDefaultCredentials(); if(!defaultCredentials){ return false; } let httpsOptions = Object.assign({}, defaultCredentials); httpsOptions.SNICallback = (hostname, callback) => { let domain = this.findDomainConfig(hostname); if(!domain || !domain.keyPath || !domain.certPath){ return callback(null, null); } let key = FileHandler.readFile(domain.keyPath, 'Domain Key'); if(!key){ this.error = {message: 'Could not read domain SSL key: '+domain.keyPath}; return callback(null, null); } let cert = FileHandler.readFile(domain.certPath, 'Domain Cert'); if(!cert){ this.error = {message: 'Could not read domain SSL certificate: '+domain.certPath}; return callback(null, null); } let ctx = require('tls').createSecureContext({key, cert}); callback(null, ctx); }; return https.createServer(httpsOptions, this.app); } loadDefaultCredentials() { let key = FileHandler.readFile(this.keyPath, 'Default Key'); if(!key){ this.error = {message: 'Could not read default SSL key file: '+this.keyPath}; return false; } let cert = FileHandler.readFile(this.certPath, 'Default Cert'); if(!cert){ this.error = {message: 'Could not read default SSL certificate file: '+this.certPath}; return false; } return {key, cert, passphrase: this.passphrase}; } listen(port) { let listenPort = port || this.port; if(!this.appServer){ this.error = {message: 'Cannot listen: app server not created'}; return false; } this.appServer.listen(listenPort); return true; } async enableServeHome(app, homePageLoadCallback) { let limiter = this.rateLimitConfigurer.createHomeLimiter(); app.post('/', limiter); app.post('/', async (req, res, next) => { if('/' === req._parsedUrl.pathname){ return res.redirect('/'); } next(); }); app.get('/', limiter); app.get('/', async (req, res, next) => { if('/' === req._parsedUrl.pathname){ if('function' !== typeof homePageLoadCallback){ let errorMessage = 'Homepage contents could not be loaded.'; if('function' === typeof this.processErrorResponse){ return this.processErrorResponse(500, errorMessage, req, res); } return res.status(500).send(errorMessage); } let homepageContent = await homePageLoadCallback(req); if(!homepageContent){ let message = 'Error loading homepage content'; this.error = {message}; if('function' === typeof this.processErrorResponse){ return this.processErrorResponse(500, message, req, res); } return res.status(500).send(message); } return res.send(homepageContent); } next(); }); } async serveStatics(app, statics) { app.use(this.applicationFramework.static(statics, this.staticOptions)); return true; } async serveStaticsPath(app, staticsPath, statics) { app.use(staticsPath, this.applicationFramework.static(statics, this.staticOptions)); return true; } addDomain(domainConfig) { if(!domainConfig || !domainConfig.hostname){ this.error = {message: 'Domain configuration missing hostname'}; return false; } if('string' !== typeof domainConfig.hostname){ this.error = {message: 'Domain hostname must be a string'}; return false; } this.domains.push(domainConfig); return true; } addDevelopmentDomain(domain) { if(!domain || 'string' !== typeof domain){ return false; } this.developmentDomains.push(domain); return true; } setDomainMapping(mapping) { if(!mapping || 'object' !== typeof mapping){ return false; } this.domainMapping = mapping; return true; } async close() { if(!this.appServer){ return true; } return this.appServer.close(); } enableCSP(cspOptions) { return this.securityConfigurer.enableCSP(this.app, cspOptions); } } module.exports.AppServerFactory = AppServerFactory;