diff --git a/src/server/Server.js b/src/server/Server.js new file mode 100644 index 0000000..4c1bcf6 --- /dev/null +++ b/src/server/Server.js @@ -0,0 +1,164 @@ +// Native +const { EventEmitter } = require('node:events'); +// const { inspect } = require('node:util'); +const path = require('node:path'); +const http = require('node:http2'); + +// External +const { LoggerClient } = require('@navy.gif/logger'); +// const { Collection } = require('@discordjs/collection'); +const express = require('express'); +const { default: helmet } = require('helmet'); + +// Local +const { Util } = require('../util'); +const { Registry, UserDatabase } = require('./components'); +const { MariaDB, MongoDB } = require('./database'); +const Authenticator = require('./middleware/Authenticator'); + +/** + * Meant to be deployed behind a proxy (e.g. nginx) instance that takes care of certs and load balancing, trusts the first proxy + * + * @class Server + * @extends {EventEmitter} + */ +class Server extends EventEmitter { + + constructor (options = {}) { + + super(); + + const { MARIA_HOST, MARIA_USER, MARIA_PORT, MARIA_PASS, MARIA_DB, + MONGO_HOST, MONGO_USER, MONGO_PORT, MONGO_PASS, MONGO_DB, + NODE_ENV, SECRET, DISCORD_SECRET } = process.env; // Secret used for cookies, discord secret is for discord oauth + const { discord, http: httpOpts, databases } = options; + + this._options = options; + this._shardId = parseInt(process.env.SHARD_ID); + this._ready = false; + this._debug = options.debug || false; + this._proto = NODE_ENV === 'development' ? 'http' : 'https'; + + if (!httpOpts?.port) return Util.fatal(new Error(`Missing http.port in server options`)); + this.port = httpOpts.port + this._shardId; + this.domain = options.domain; + + + this.server = null; + this.app = express(); + + this.registry = new Registry(this, { path: path.join(__dirname, 'endpoints') }); + this.logger = new LoggerClient({ ...options.logger, name: this.constructor.name }); + + this.mariadb = new MariaDB(this, { options: databases.mariadb, MARIA_HOST, MARIA_USER, MARIA_PORT, MARIA_PASS, MARIA_DB }); + this.mongodb = new MongoDB(this, { options: databases.mongodb, MONGO_HOST, MONGO_USER, MONGO_PORT, MONGO_PASS, MONGO_DB }); + this.userDatabase = new UserDatabase(this.mongodb); + this.authenticator = new Authenticator(this, this.app, this.userDatabase, { + mongo: this.mongodb.client, + secret: SECRET, + discordID: discord.id, + discordScope: discord.scope, + discordSecret: DISCORD_SECRET, + discordVersion: discord.version, + callbackURL: `${this.baseURL}${discord.callbackURL}`, + cookie: { + secure: true + } + }); + + // Expecting every other env to be behind a proxy + if (NODE_ENV !== 'development') this.app.set('trust proxy', 1); + + this.app.use(helmet()); + this.app.use(express.json({ limit: '10mb' })); + this.app.use(this.logRequest.bind(this)); // Logs every request + this.app.use(this.logError.bind(this)); // Logs endpoints that error and sends a 500 + this.app.use(this.ready.bind(this)); // denies requests before the server is ready + + process.on('message', this._handleMessage.bind(this)); + + } + + async init () { + + const start = Date.now(); + this.logger.status('Starting server'); + + this.logger.info('Initialising MariaDB'); + this.mariadb.init(); + + this.logger.info('Initialising MongoDB'); + await this.mongodb.init(); + + this.userDatabase.init(); + + this.logger.info('Loading endpoints'); + this.registry.loadEndpoints(); + this.logger.debug(this.registry.print); + + this.logger.info('Creating http server'); + this.server = http.createServer(this._options.http, this.app).listen(this.port); + this._ready = true; + + this.logger.status(`Server created, took ${Date.now() - start} ms, listening on port ${this.port}`); + process.send({ _ready: true }); + + } + + // eslint-disable-next-line no-unused-vars + logError (err, req, res, next) { + this.logger.error(`Unhandled error:\n${err.stack || err}`); + res.status(500).send('An internal error was encountered'); + } + + logRequest (req, res, next) { + res.once('finish', () => { + this.logger[res.statusCode === 401 ? 'unauthorised' : 'access'](`[${req.get('X-Forwarded-For') || req.socket.remoteAddress}] [STATUS: ${res.statusCode}] Request to ${req.route?.path || req.path}`); + }); + next(); + } + + ready (req, res, next) { + if (!this._ready) return res.status(503).send('Server not ready'); + next(); + } + + async shutdown () { + this.logger.info(`Received shutdown command, initiating graceful shutdown`); + process.send({ _shutdown: true }); + + this._ready = false; // stops any new connections + + await this.mongodb.close(); + await this.mariadb.close(); + this.logger.status('DB shutdowns complete.'); + + this.logger.status('Shutdown complete. Goodbye'); + // eslint-disable-next-line no-process-exit + process.exit(); + + } + + _handleMessage (msg) { + if (msg._shutdown) this.shutdown(); + } + + createLogger (comp) { + return new LoggerClient({ name: comp.constructor.name, ...this._options.logger }); + } + + get baseURL () { + return `${this._proto}://${this.domain}/`; + } + +} + +process.once('message', (msg) => { + if (msg._start) { + const options = msg._start; + const server = new Server(options); + server.init(); + } +}); + +module.exports = Server; \ No newline at end of file