// webapp.js // Copyright (C) 2022 Rob Colbert @rob@nicecrew.digital // License: Apache-2.0 'use strict'; import 'dotenv/config'; // reads .env into process.env import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line import fs from 'fs'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); // jshint ignore:line import express from 'express'; import multer from 'multer'; import session from 'express-session'; import ConnectRedis from 'connect-redis'; const RedisSessionStore = ConnectRedis(session); import { v4 as uuidv4 } from 'uuid'; import Redis from 'ioredis'; import ioEmitter from 'socket.io-emitter'; import CanvasIoServer from './canvas-io-server.js'; import CanvasImage from './lib/canvas-image.js'; import compress from 'compression'; import methodOverride from 'method-override'; import { marked } from 'marked'; import striptags from 'striptags'; import winston from 'winston'; import expressWinston from 'express-winston'; import webpack from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; import WEBPACK_CONFIG from './webpack.config.js'; const APP_CONFIG = { pkg: require('./package.json'), niceGameSdk: require('./node_modules/dtp-nice-game/package.json'), }; class MultiplayerCanvasApp { constructor ( ) { this.app = express(); this.app.set('view engine', 'pug'); this.app.set('views', path.join(__dirname, 'app', 'views')); this.app.set('x-powered-by', false); this.app.locals.config = APP_CONFIG; this.app.locals.pkg = APP_CONFIG.pkg; this.app.locals.niceGameSdk = APP_CONFIG.niceGameSdk; /* * Set up the protected markdown renderer that will refuse to process links and images * for security reasons. */ function safeImageRenderer (href, title, text) { return text; } function safeLinkRenderer (href, title, text) { return text; } this.app.locals.tileMarkedRenderer = new marked.Renderer(); this.app.locals.fullMarkedRenderer = new marked.Renderer(); this.app.locals.fullMarkedRenderer.image = safeImageRenderer; this.app.locals.safeMarkedRenderer = new marked.Renderer(); this.app.locals.safeMarkedRenderer.link = safeLinkRenderer; this.app.locals.safeMarkedRenderer.image = safeImageRenderer; marked.setOptions({ renderer: this.app.locals.safeMarkedRenderer }); this.app.locals.marked = marked; /* * HTTP request logging */ this.app.use(expressWinston.logger({ transports: [ new winston.transports.Console(), ], format: winston.format.combine( winston.format.colorize(), // winston.format.json(), ), meta: false, msg: "HTTP ", expressFormat: true, colorize: false, ignoreRoute: (/*req, res*/) => { return false; }, })); } async start ( ) { this.redis = new Redis({ host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, password: process.env.REDIS_PASSWORD, key: process.env.REDIS_KEY_PREFIX || 'dtp', }); this.redis.on('error', this.onRedisError.bind(this)); /* * Order matters. * 1. Create the HTTP server * 2. Attach the I/O server to that * 3. Create the Express app on that */ await this.createHttpServer(); await this.createIoServer(); await this.createExpress(); await this.registerRoutes(); await this.registerDefaultErrorHandler(); /* * Start the HTTP server. * The HTTP server then "drives" both IoServer and Express. */ return new Promise((resolve, reject) => { const HTTP_PORT = parseInt(process.env.HTTP_BIND_PORT || '3000', 10); const HTTP_HOST = process.env.HTTP_BIND_ADDRESS || '127.0.0.1'; this.httpServer.listen(HTTP_PORT, HTTP_HOST, (err) => { if (err) { return reject(err); } console.log('start', `${APP_CONFIG.pkg.name} online`); resolve(); }); }); } async createHttpServer ( ) { console.log('start', 'creating HTTP server'); this.httpServer = require('http').createServer(this.app); } async createIoServer ( ) { /* * Socket.io emitter allows any host to send messages without actually being * a connected session in that system. It "emits" the message directly via * the Socket.io Redis transport as if it's a member of a session. */ this.ioEmitter = ioEmitter(this.redis); this.io = new CanvasIoServer(this); await this.io.start(); } async createExpress ( ) { /* * Static content routes that are not served by Webpack at runtime in development * environments. */ this.app.use('/dist/assets', express.static(path.join(__dirname, 'app', 'assets'))); /* * Either serve the /dist route as static content, or allow it to be served by * Webpack dev middleware, depending on environment configuration. The * production environment serves /dist as static content, which must be * committed in git (prod servers don't perform builds, we ship builds). */ if (process.env.NODE_ENV === 'production') { this.app.use('/dist', express.static(path.join(__dirname, 'dist'))); } else { this.compiler = webpack(WEBPACK_CONFIG); this.webpackDevMiddleware = webpackDevMiddleware(this.compiler, { publicPath: WEBPACK_CONFIG.output.publicPath, }); this.app.use(this.webpackDevMiddleware); } /* * ExpressJS middleware */ this.app.use(express.json({ })); this.app.use(express.urlencoded({ extended: true })); this.app.use(compress()); this.app.use(methodOverride()); /* * ExpressJS session support */ console.log('initializing redis session store'); var sessionStore = new RedisSessionStore({ client: this.redis }); const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days this.sessionConfig = { name: `session.${APP_CONFIG.pkg.name}.${process.env.NODE_ENV}`, secret: process.env.HTTP_SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { domain: process.env.NODE_ENV === 'production' ? process.env.DTP_SITE_DOMAIN : 'localhost', path: '/', httpOnly: true, secure: false, sameSite: false, maxAge: SESSION_DURATION, }, store: sessionStore, }; if (process.env.NODE_ENV === 'production') { this.app.set('trust proxy', 1); } this.app.use(session(this.sessionConfig)); } /** * Register the ExpressJS app request routes. */ async registerRoutes ( ) { const upload = multer({ dest: path.join('/tmp', 'dtp', 'mpcvs', 'canvas') }); this.app.param('imageId', this.populateImageId.bind(this)); this.app.post('/', upload.none(), this.postCreateCanvas.bind(this)); this.app.get('/create', this.getCanvasEditor.bind(this)); this.app.get('/:imageId/image', this.getCanvasImage.bind(this)); this.app.get('/:imageId/obs', this.getObsWidget.bind(this)); this.app.get('/:imageId/edit', this.getCanvasEditor.bind(this)); this.app.get('/:imageId', this.getCanvasView.bind(this)); this.app.get('/', this.getHomeView.bind(this)); } async registerDefaultErrorHandler ( ) { /* * Default error handler */ console.log('registering default error handler'); this.app.use((error, req, res, next) => { // jshint ignore:line res.locals.errorCode = error.statusCode || error.status || 500; console.log('App error', { url: req.url, error }); res.status(res.locals.errorCode).render('error', { message: error.message, error, errorCode: res.locals.errorCode, }); }); } async populateImageId (req, res, next, imageId) { try { const image = new CanvasImage(imageId); const imageMetadata = await this.redis.hgetall(image.key); res.locals.image = { id: imageId, ...imageMetadata, }; if (!res.locals.image) { throw new Error('Image not found'); } return next(); } catch (error) { return next(error); } } async postCreateCanvas (req, res, next) { try { const image = { id: uuidv4() }; if (!req.body.name || !req.body.name.length) { return next(new Error('Must include a name for your battle canvas')); } image.name = striptags(req.body.name.trim()); if (!image.name.length) { return next(new Error('Must include a name for your battle canvas')); } if (!req.body.width) { return next(new Error('Invalid canvas width')); } image.width = parseInt(req.body.width, 10); if (image.width < 32 || image.width > 128) { return next(new Error('Invalid canvas width')); } if (!req.body.height) { return next(new Error('Invalid canvas height')); } image.height = parseInt(req.body.height, 10); if (image.height < 32 || image.height > 128) { return next(new Error('Invalid canvas height')); } if (!req.body['limit.pixels']) { return next(new Error('Invalid pixel limit')); } image.limit = { pixels: parseInt(req.body['limit.pixels'], 10), }; if (image.limit.pixels < 10 || image.limit.pixels > 60) { return next(new Error('Invalid pixel limit')); } this.redis.publish('mpcvs:admin', JSON.stringify({ command: 'image-create', image, })); } catch (error) { return next(error); } } async getObsWidget (req, res) { this.createConnectToken(req, res); res.locals.viewMode = 'obs'; res.locals.appModuleUrl = '/dist/canvas-app.bundle.js'; res.render('canvas/obs'); } async getCanvasImage (req, res, next) { try { const image = new CanvasImage(res.locals.image.id); const filename = image.filename; const stat = await fs.promises.stat(filename); const rs = fs.createReadStream(filename); res.set('Content-Type', 'image/png'); res.set('Content-Length', stat.size); rs.pipe(res); } catch (error) { return next(error); } } async getCanvasView (req, res) { this.createConnectToken(req, res); res.locals.appModuleUrl = '/dist/canvas-app.bundle.js'; res.render('canvas/view'); } async getCanvasEditor (req, res) { res.render('canvas/editor'); } async getHomeView (req, res, next) { try { res.locals.images = await CanvasImage.getImages(this.redis); res.render('home'); } catch (error) { return next(error); } } async createConnectToken (req, res) { res.locals.connectToken = uuidv4(); await this.redis.setex(`connect-token:${res.locals.connectToken}`, 30, JSON.stringify({ imageId: res.locals.image.id, ip: req.ip, })); return res.locals.connectToken; } async onRedisError (error) { console.log('Redis error', error); } } (async ( ) => { const app = new MultiplayerCanvasApp(); await app.start(); })();