A "multiplayer" HTML5 <canvas> to which people can connect and make changes over time.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

382 lines
11 KiB

// webapp.js
// Copyright (C) 2022 Rob Colbert @[email protected]
// 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();
})();