UNPKG

@ladjs/web

Version:
478 lines (408 loc) 13.9 kB
const process = require('node:process'); const http = require('node:http'); const http2 = require('node:http2'); const path = require('node:path'); const util = require('node:util'); const zlib = require('node:zlib'); const Cabin = require('cabin'); const CacheResponses = require('@ladjs/koa-cache-responses'); const I18N = require('@ladjs/i18n'); const Koa = require('koa'); const Meta = require('koa-meta'); const Passport = require('@ladjs/passport'); const RedirectLoop = require('koa-redirect-loop'); const Redis = require('@ladjs/redis'); const StateHelper = require('@ladjs/state-helper'); const StoreIPAddress = require('@ladjs/store-ip-address'); const Timeout = require('koa-better-timeout'); const _ = require('lodash'); const auth = require('koa-basic-auth'); const bodyParser = require('koa-bodyparser'); const compress = require('koa-compress'); const conditional = require('koa-conditional-get'); const cors = require('kcors'); const cryptoRandomString = require('crypto-random-string'); const errorHandler = require('koa-better-error-handler'); const etag = require('koa-etag'); const favicon = require('koa-favicon'); const flash = require('koa-better-flash'); const helmet = require('koa-helmet'); const isSANB = require('is-string-and-not-blank'); const isajax = require('@ladjs/koa-isajax'); const json = require('koa-json'); const koa404Handler = require('koa-404-handler'); const koaCash = require('koa-cash'); const koaConnect = require('koa-connect'); const methodOverride = require('koa-methodoverride'); const ms = require('ms'); const ratelimit = require('@ladjs/koa-simple-ratelimit'); const redisStore = require('koa-redis'); const requestId = require('express-request-id'); const requestReceived = require('request-received'); const responseTime = require('response-time'); const serveStatic = require('@ladjs/koa-better-static'); const session = require('koa-generic-session'); const sharedConfig = require('@ladjs/shared-config'); const views = require('@ladjs/koa-views'); const { boolean } = require('boolean'); // https://gist.github.com/titanism/241fc0c5f1c1a0b7cae3d97580e435fb function removeTrailingSlashes(ctx, next) { const { path, search } = ctx.request; if (path !== '/' && !path.startsWith('//') && path.slice(-1) === '/') { const redirectUrl = path.slice(0, -1) + search; ctx.response.status = 301; ctx.redirect(redirectUrl); return; } return next(); } const defaultSrc = isSANB(process.env.WEB_HOST) ? [ "'self'", 'data:', `*.${process.env.WEB_HOST}`, `*.${process.env.WEB_HOST}:*`, process.env.WEB_HOST, `${process.env.WEB_HOST}:*` ] : null; const reportUri = isSANB(process.env.WEB_URL) ? `${process.env.WEB_URL}/report` : null; class Web { // eslint-disable-next-line complexity constructor(config, Users) { const sharedWebConfig = sharedConfig('WEB'); this.config = { ...sharedWebConfig, meta: {}, views: { root: path.resolve('./app/views'), locals: {}, options: { extension: 'pug' } }, rateLimit: { ...sharedWebConfig.rateLimit, ignoredPathGlobs: ['/report'] }, sessionKeys: process.env.SESSION_KEYS ? process.env.SESSION_KEYS.split(',') : ['lad'], cookiesKey: process.env.COOKIES_KEY || 'lad.sid', favicon: { path: path.resolve('./assets/img/favicon.ico'), options: {} }, buildDir: path.resolve('./build'), // <https://github.com/ladjs/koa-better-static#options> serveStatic: {}, // <https://github.com/ladjs/koa-redirect-loop> redirectLoop: {}, // <https://github.com/koajs/cash> koaCash: false, // <https://github.com/ladjs/koa-cache-responses> cacheResponses: false, genSid() { return cryptoRandomString.async({ length: 32 }); }, methodOverride: [ (request) => { const { _method } = request.body; if (_method && typeof _method === 'string') return _method; } ], helmet: { contentSecurityPolicy: defaultSrc ? { directives: { defaultSrc, connectSrc: defaultSrc, fontSrc: defaultSrc, imgSrc: defaultSrc, styleSrc: [...defaultSrc, "'unsafe-inline'"], scriptSrc: [...defaultSrc, "'unsafe-inline'"], reportUri: reportUri || null } } : null, // Expect-CT header is deprecated expectCt: false, /* expectCt: { enforce: true, // https://httpwg.org/http-extensions/expect-ct.html#maximum-max-age maxAge: ms('30d') / 1000, reportUri }, */ // <https://hstspreload.org/> // <https://helmetjs.github.io/docs/hsts/#preloading-hsts-in-chrome> hsts: { // must be at least 1 year to be approved maxAge: ms('1y') / 1000, // must be enabled to be approved includeSubDomains: true, preload: true }, // <https://helmetjs.github.io/docs/referrer-policy> // <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy> referrerPolicy: { policy: 'same-origin' }, xssFilter: { reportUri } }, // https://github.com/koajs/compress compress: { br: { params: { [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } } }, ...config }; // initialize the app const app = new Koa(); // only trust proxy if enabled app.proxy = boolean(process.env.TRUST_PROXY); // inherit cache variable for cache-pug-templates app.cache = boolean(this.config.views.locals.cache); // initialize cabin this.logger = _.isPlainObject(this.config.logger) ? new Cabin(this.config.logger) : this.config.logger instanceof Cabin ? this.config.logger : new Cabin({ logger: this.config.logger || console }); app.context.logger = this.logger; // initialize redis this.client = this.config.redis === false ? false : _.isPlainObject(this.config.redis) ? new Redis(this.config.redis, this.logger, this.config.redisMonitor) : this.config.redis; app.context.client = this.client; // expose passport this.passport = this.config.passport === false ? false : _.isPlainObject(this.config.passport) ? new Passport(this.config.passport, Users) : this.config.passport; app.context.passport = this.passport; // listen for errors emitted by app app.on('error', (err, ctx) => { ctx.logger[err.status && err.status < 500 ? 'warn' : 'error'](err); }); // override koa's undocumented error handler app.context.onerror = errorHandler(this.config.cookiesKey); // adds request received hrtime and date symbols to request object // (which is used by Cabin internally to add `request.timestamp` to logs app.use(requestReceived); // configure timeout if (this.config.timeout) { const timeout = new Timeout(this.config.timeout); app.use(timeout.middleware); } // adds `X-Response-Time` header to responses app.use(koaConnect(responseTime())); // adds or re-uses `X-Request-Id` header app.use(koaConnect(requestId())); // add cabin middleware app.use(this.logger.middleware); // allow before hooks to get setup if (_.isFunction(this.config.hookBeforeSetup)) this.config.hookBeforeSetup(app); // basic auth if (this.config.auth) app.use(auth(this.config.auth)); // remove trailing slashes app.use(removeTrailingSlashes); // security // (needs to come before i18n so HSTS header gets added) if (this.config.helmet) app.use(helmet(this.config.helmet)); // i18n if (this.config.i18n) { // create new @ladjs/i18n instance const i18n = this.config.i18n.config ? this.config.i18n : new I18N({ ...this.config.i18n, logger: this.logger }); // setup localization (must come before `i18n.redirect`) app.use(i18n.middleware); // detect or redirect based off locale url app.use(i18n.redirect); } app.use((ctx, next) => { // add limited `ctx` object to the state for views ctx.state.ctx = {}; ctx.state.ctx.get = ctx.get.bind(ctx); ctx.state.ctx.locale = ctx.locale; ctx.state.ctx.path = ctx.path; ctx.state.ctx.pathWithoutLocale = ctx.pathWithoutLocale; ctx.state.ctx.query = ctx.query; ctx.state.ctx.url = ctx.url; return next(); }); // conditional-get app.use(conditional()); // etag app.use(etag()); // cors if (this.config.cors) app.use(cors(this.config.cors)); // compress/gzip if (this.config.compress) app.use(compress(this.config.compress)); // cache support if (this.config.koaCash) app.use(koaCash(this.config.koaCash)); // cache responses if (this.config.cacheResponses) { this.cacheResponses = new CacheResponses(this.config.cacheResponses); app.use(this.cacheResponses.middleware); } // favicons if (this.config.favicon) app.use(favicon(this.config.favicon.path, this.config.favicon.options)); // serve static assets if (this.config.buildDir && this.config.serveStatic) app.use(serveStatic(this.config.buildDir, this.config.serveStatic)); // set template rendering engine app.use( views( this.config.views.root, _.extend(this.config.views.options, this.config.views.locals) ) ); // ajax request detection (sets `ctx.state.xhr` boolean) app.use(isajax()); // // add support for SEO <title> and <meta name="description"> // // NOTE: this must come after ctx.render is added (via koa-views) // if (this.config.meta) { const meta = new Meta(this.config.meta, this.logger); app.use(meta.middleware); } // add dynamic view helpers const stateHelper = new StateHelper(this.config.views.locals); app.use(stateHelper.middleware); // session store app.keys = this.config.sessionKeys; app.use( session({ ...this.config.session, ...(this.client ? { store: redisStore({ client: this.client }) } : {}), key: this.config.cookiesKey, cookie: this.config.cookies, genSid: this.config.genSid }) ); app.use((ctx, next) => { ctx.state.ctx.sessionId = ctx.sessionId; return next(); }); // redirect loop (must come after sessions added) if (this.config.redirectLoop) { const redirectLoop = new RedirectLoop({ ...this.config.redirectLoop, logger: this.logger }); app.use(redirectLoop.middleware); } // flash messages (must come after sessions added) app.use(flash()); // body parser app.use(bodyParser()); // pretty-printed json responses app.use(json()); // method override // (e.g. `<input type="hidden" name="_method" value="PUT" />`) if (this.config.methodOverride) app.use(methodOverride(...this.config.methodOverride)); // allow hooks before passport to get setup if (_.isFunction(this.config.hookBeforePassport)) this.config.hookBeforePassport(app); // passport if (this.passport) { app.use(this.passport.initialize()); app.use(this.passport.session()); } // add specific locals app.use((ctx, next) => { // passport-related helpers (e.g. for rendering log in with X buttons) ctx.state.passport = ctx.passport ? {} : false; if ( ctx.passport && ctx.passport.config && ctx.passport.config.providers ) { for (const key of Object.keys(ctx.passport.config.providers)) { ctx.state.passport[key] = boolean(ctx.passport.config.providers[key]); } } return next(); }); // rate limiting if (this.client && this.config.rateLimit) app.use( ratelimit({ ...this.config.rateLimit, db: this.client, logger: this.logger }) ); // store the user's last ip address in the background if (this.config.storeIPAddress) { const storeIPAddress = new StoreIPAddress({ ...this.config.storeIPAddress, logger: this.logger }); app.use(storeIPAddress.middleware); } // 404 handler app.use(koa404Handler); // allow before hooks to get setup if (_.isFunction(this.config.hookBeforeRoutes)) this.config.hookBeforeRoutes(app); // mount the app's defined and nested routes if (this.config.routes) { if (_.isFunction(this.config.routes.routes)) app.use(this.config.routes.routes()); else app.use(this.config.routes); } // start server on either http or http2 this.server = this.config.protocol === 'https' ? http2.createSecureServer(this.config.ssl, app.callback()) : http.createServer(app.callback()); // expose the app this.app = app; // bind listen/close to this this.listen = this.listen.bind(this); this.close = this.close.bind(this); } async listen( port = this.config.port, host = this.config.serverHost, ...args ) { await util.promisify(this.server.listen).bind(this.server)( port, host, ...args ); } async close() { await util.promisify(this.server.close).bind(this.server)(); } } module.exports = Web;