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.
296 lines
8.4 KiB
296 lines
8.4 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 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 { marked } from 'marked';
|
|
|
|
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.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();
|
|
|
|
/*
|
|
* 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_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 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));
|
|
}
|
|
|
|
async registerRoutes ( ) {
|
|
this.app.param('imageId', this.populateImageId.bind(this));
|
|
|
|
this.app.get('/:imageId/image', this.getCanvasImage.bind(this));
|
|
this.app.get('/:imageId/obs', this.getObsWidget.bind(this));
|
|
this.app.get('/:imageId', this.getCanvasView.bind(this));
|
|
this.app.get('/', this.getHomeView.bind(this));
|
|
}
|
|
|
|
async populateImageId (req, res, next, imageId) {
|
|
try {
|
|
const image = new CanvasImage(imageId);
|
|
res.locals.image = {
|
|
id: imageId,
|
|
image: await this.redis.hgetall(image.key),
|
|
};
|
|
if (!res.locals.image) {
|
|
throw new Error('Image not found');
|
|
}
|
|
return next();
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
|
|
async getObsWidget (req, res, next) {
|
|
this.createConnectToken(req, res);
|
|
|
|
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 getImageCreateForm (req, res) {
|
|
res.render('canvas/editor');
|
|
}
|
|
|
|
async getHomeView (req, res, next) {
|
|
try {
|
|
res.locals.images = [ ];
|
|
for (const key of await this.redis.keys('mpcvs:images:*')) {
|
|
const image = await this.redis.hgetall(key);
|
|
const tokens = key.split(':');
|
|
const id = tokens[tokens.length - 1];
|
|
res.locals.images.push({ id, image });
|
|
}
|
|
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: 'test-image',
|
|
ip: req.ip,
|
|
}));
|
|
return res.locals.connectToken;
|
|
}
|
|
|
|
async onRedisError (error) {
|
|
console.log('Redis error', error);
|
|
}
|
|
}
|
|
|
|
(async ( ) => {
|
|
|
|
const app = new MultiplayerCanvasApp();
|
|
await app.start();
|
|
|
|
})();
|