virtualtabletop-docker/server.mjs

485 lines
18 KiB
JavaScript
Raw Normal View History

2020-10-30 22:15:51 +08:00
import fs from 'fs';
import path from 'path';
import v8 from 'v8';
import express from 'express';
import bodyParser from 'body-parser';
import http from 'http';
import CRC32 from 'crc-32';
2023-09-20 22:23:00 +08:00
import fetch from 'node-fetch';
2023-10-04 03:30:25 +08:00
import crawlers from 'crawler-user-agents' assert { type: 'json' };;
import WebSocket from './server/websocket.mjs';
import Player from './server/player.mjs';
import Room from './server/room.mjs';
2023-09-20 22:23:00 +08:00
import MinifyHTML from './server/minify.mjs';
import Logging from './server/logging.mjs';
import Config from './server/config.mjs';
2022-12-30 17:46:55 +08:00
import Statistics from './server/statistics.mjs';
const app = express();
const server = http.Server(app);
const router = express.Router();
const savedir = Config.directory('save');
const assetsdir = Config.directory('assets');
const sharedLinks = fs.existsSync(savedir + '/shares.json') ? JSON.parse(fs.readFileSync(savedir + '/shares.json')) : {};
const serverStart = +new Date();
app.use(Config.get('urlPrefix'), router);
fs.mkdirSync(assetsdir, { recursive: true });
fs.mkdirSync(savedir + '/rooms', { recursive: true });
fs.mkdirSync(savedir + '/states', { recursive: true });
fs.mkdirSync(savedir + '/links', { recursive: true });
fs.mkdirSync(savedir + '/errors', { recursive: true });
2020-10-30 22:15:51 +08:00
async function ensureRoomIsLoaded(id) {
if(!id.match(/^[A-Za-z0-9_-]+$/))
return false;
if(!activeRooms.has(id)) {
const room = new Room(id, function() {
activeRooms.delete(id);
}, function() {
Logging.log(`The public library was edited in room ${id}. Reloading in every room...`);
for(const [ _, room ] of activeRooms)
room.reloadPublicLibraryGames();
});
await room.load();
activeRooms.set(id, room);
}
return true;
}
2024-01-26 16:59:10 +08:00
function getEmptyRoomID() {
let id = null;
while(!id || fs.existsSync(savedir + '/rooms/' + id + '.json'))
id = Math.random().toString(36).substring(3, 7);
return id;
}
2022-12-30 17:46:55 +08:00
function validateInput(res, next, values) {
for(const value of values) {
if(value && !value.match(/^[A-Za-z0-9.: _-]+$/)) {
next(new Logging.UserError(403, 'Invalid characters in parameters'));
return false;
}
}
return true;
}
2021-01-28 05:35:49 +08:00
async function downloadState(res, roomID, stateID, variantID) {
if(await ensureRoomIsLoaded(roomID)) {
const d = await activeRooms.get(roomID).download(stateID, variantID);
res.setHeader('Content-Type', d.type);
res.setHeader('Content-Disposition', `attachment; filename="${d.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`);
res.send(d.content);
}
}
2021-01-28 01:21:41 +08:00
function autosaveRooms() {
setInterval(function() {
for(const [ _, room ] of activeRooms) {
try {
2022-12-30 17:46:55 +08:00
room.updateTimeStatistics();
room.writeToFilesystem();
} catch(e) {
Logging.handleGenericException('autosaveRooms', e);
}
}
2022-12-30 17:46:55 +08:00
Statistics.writeToFilesystem();
2021-01-28 01:21:41 +08:00
}, 60*1000);
}
2023-09-20 22:23:00 +08:00
MinifyHTML().then(function(result) {
router.use('/', express.static(path.resolve() + '/client'));
2023-09-29 13:36:23 +08:00
if(Config.get('adminURL')) {
router.get(Config.get('adminURL'), function(req, res, next) {
let output = '<h1>Active rooms</h1>';
for(const [ roomID, room ] of activeRooms) {
let game = '';
if(room.state && room.state._meta && room.state._meta.activeState && room.state._meta.states && room.state._meta.states[room.state._meta.activeState.stateID])
game = ` playing ${room.state._meta.states[room.state._meta.activeState.stateID].name}`;
output += `<p><b><a href='${roomID}'>${roomID}</a></b>${game}: ${room.players.map(p=>p.name).join(', ')} (${room.deltaID} deltas transmitted)</p>`;
}
res.send(output);
});
}
// fonts.css is specifically made available for use from card html iframe. It must
// be fetched from the root in order for the relative paths to fonts to work.
// Additionally allow cached use of fonts for a short period of time to allow
// immediate rendering in subframes.
function cache5m(req, res, next) {
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Expires', new Date(Date.now() + 300000).toUTCString());
next();
}
router.get('/fonts.css', cache5m);
router.get('/i/fonts/', cache5m);
router.use('/fonts.css', express.static(path.resolve() + '/client/css/fonts.css'));
router.use('/i', express.static(path.resolve() + '/assets'));
2022-12-30 17:46:55 +08:00
router.get('/scripts/:name', function(req, res) {
res.setHeader('Content-Type', 'application/javascript');
if(req.params.name == 'jszip')
res.send(fs.readFileSync('node_modules/jszip/dist/jszip.min.js'));
});
router.post('/assetcheck', bodyParser.json({ limit: '10mb' }), function(req, res) {
const result = {};
if(Array.isArray(req.body))
for(const asset of req.body)
if(asset.match(/^[0-9_-]+$/))
2022-12-30 17:46:55 +08:00
result[asset] = !!Config.resolveAsset(asset);
res.send(result);
});
router.get('/assets/:name', function(req, res) {
2022-12-30 17:46:55 +08:00
if(!req.params.name.match(/^[0-9_-]+$/) || !Config.resolveAsset(req.params.name)) {
res.sendStatus(404);
return;
}
2022-12-30 17:46:55 +08:00
fs.readFile(Config.resolveAsset(req.params.name), function(err, content) {
if(!content) {
res.sendStatus(404);
2021-04-14 21:13:16 +08:00
Logging.log(`WARNING: Could not load asset ${req.params.name}`);
return;
}
2020-11-19 23:01:15 +08:00
if(content[0] == 0xff)
res.setHeader('Content-Type', 'image/jpeg');
else if(content[0] == 0x89)
res.setHeader('Content-Type', 'image/png');
else if(content[0] == 0x3c)
res.setHeader('Content-Type', 'image/svg+xml');
else if(content[0] == 0x47)
res.setHeader('Content-Type', 'image/gif');
else if(content[0] == 0x52)
res.setHeader('Content-Type', 'image/webp');
else
2021-04-14 21:13:16 +08:00
Logging.log(`WARNING: Unknown file type of asset ${req.params.name}`);
2021-10-05 16:24:32 +08:00
res.setHeader('Cache-Control', 'public, max-age=30000000');
res.setHeader('Expires', new Date(Date.now() + 30000000000).toUTCString());
2020-11-19 23:01:15 +08:00
res.send(content);
});
});
router.post('/heapsnapshot', function(req, res) {
v8.getHeapSnapshot().pipe(fs.createWriteStream('memory.heapsnapshot'));
});
router.post('/quit', function(req, res) {
process.exit();
});
router.get('/', function(req, res) {
2024-01-26 16:59:10 +08:00
res.redirect(getEmptyRoomID());
});
router.get('/dl/:room/:state/:variant', function(req, res, next) {
downloadState(res, req.params.room, req.params.state, req.params.variant).catch(next);
});
router.get('/dl/:room/:state', function(req, res, next) {
downloadState(res, req.params.room, req.params.state).catch(next);
});
router.get('/dl/:room', function(req, res, next) {
downloadState(res, req.params.room).catch(next);
});
2022-12-30 17:46:55 +08:00
function allowCORS(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.sendStatus(200);
2022-12-30 17:46:55 +08:00
}
router.options('/state/:room', allowCORS);
router.get('/state/:room', function(req, res, next) {
ensureRoomIsLoaded(req.params.room).then(function(isLoaded) {
if(isLoaded) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
const state = {...activeRooms.get(req.params.room).state};
delete state._meta;
res.send(JSON.stringify(state, null, ' '));
2024-01-26 16:59:10 +08:00
} else {
res.status(404).send('Invalid room.');
}
}).catch(next);
});
router.put('/state/:room', bodyParser.json({ limit: '10mb' }), function(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*');
if(typeof req.body == 'object') {
ensureRoomIsLoaded(req.params.room).then(function(isLoaded) {
if(isLoaded) {
activeRooms.get(req.params.room).setState(req.body);
res.send('OK');
2024-01-26 16:59:10 +08:00
} else {
res.status(404).send('Invalid room.');
}
}).catch(next);
} else {
res.send('not a valid JSON object');
}
});
2022-12-30 17:46:55 +08:00
router.options('/api/addShareToRoom/:room/:share', allowCORS);
router.get('/api/addShareToRoom/:room/:share', function(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*');
2024-01-26 16:59:10 +08:00
const isPublicLibraryGame = req.params.share.match(/^PL:(game|tutorial):([a-z-]+)$/);
if(!isPublicLibraryGame && !sharedLinks[`/s/${req.params.share}`])
2022-12-30 17:46:55 +08:00
return res.sendStatus(404);
2024-01-26 16:59:10 +08:00
ensureRoomIsLoaded(req.params.room).then(async function(isLoaded) {
2022-12-30 17:46:55 +08:00
if(isLoaded) {
2024-01-26 16:59:10 +08:00
const newStateID = await activeRooms.get(req.params.room).addShare(req.params.share);
res.send(newStateID);
} else {
res.status(404).send('Invalid room.');
2022-12-30 17:46:55 +08:00
}
}).catch(next);
});
2024-01-26 16:59:10 +08:00
async function shareDetails(shareID) {
const isPublicLibraryGame = shareID.match(/^PL:(game|tutorial):([a-z-]+)$/);
if(!isPublicLibraryGame && !sharedLinks[`/s/${shareID}`])
return null;
const roomID = isPublicLibraryGame ? 'dummy' : sharedLinks[`/s/${shareID}`].split('/')[2];
const stateID = isPublicLibraryGame ? shareID : sharedLinks[`/s/${shareID}`].split('/')[3];
if(!await ensureRoomIsLoaded(roomID))
return null;
return Object.assign({}, activeRooms.get(roomID).getStateDetails(stateID), { emptyRoomID: getEmptyRoomID() });
}
2022-12-30 17:46:55 +08:00
router.options('/api/shareDetails/:share', allowCORS);
2024-01-26 16:59:10 +08:00
router.get('/api/shareDetails/:share', async function(req, res, next) {
2022-12-30 17:46:55 +08:00
res.setHeader('Access-Control-Allow-Origin', '*');
2024-01-26 16:59:10 +08:00
try {
const details = await shareDetails(req.params.share);
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(details));
} catch(e) {
return res.status(404).send('Invalid share.');
}
2022-12-30 17:46:55 +08:00
});
router.get('/s/:link/:junk', function(req, res, next) {
if(!sharedLinks[`/s/${req.params.link}`])
2024-01-26 16:59:10 +08:00
return res.status(404).send('Invalid share.');
const tokens = sharedLinks[`/s/${req.params.link}`].split('/');
downloadState(res, tokens[2], tokens[3]).catch(next);
});
2022-12-30 17:46:55 +08:00
router.get('/share/:room/:state', function(req, res, next) {
const target = `/dl/${req.params.room}/${req.params.state}`;
for(const link in sharedLinks)
if(sharedLinks[link] == target)
2024-01-26 16:59:10 +08:00
return res.send(Config.get('urlPrefix') + link.replace(/^\/s\//, '/game/'));
2022-12-30 17:46:55 +08:00
ensureRoomIsLoaded(req.params.room).then(function(isLoaded) {
if(isLoaded)
activeRooms.get(req.params.room).writeToFilesystem();
const newLink = `/s/${Math.random().toString(36).substring(3, 11)}`;
sharedLinks[newLink] = target;
fs.writeFileSync(savedir + '/shares.json', JSON.stringify(sharedLinks));
2024-01-26 16:59:10 +08:00
res.send(Config.get('urlPrefix') + newLink.replace(/^\/s\//, '/game/'));
2022-12-30 17:46:55 +08:00
}).catch(next);
});
2023-09-20 22:23:00 +08:00
router.get('/edit.js', function(req, res, next) {
res.setHeader('Content-Type', 'text/javascript');
if(req.headers['accept-encoding'] && req.headers['accept-encoding'].match(/\bgzip\b/)) {
res.setHeader('Content-Encoding', 'gzip');
res.send(result.editorJSgzipped);
} else {
res.send(result.editorJSmin);
}
});
2023-10-04 03:30:25 +08:00
function createBotPattern(crawlers) {
// Join all the patterns using the | operator
const combinedPattern = crawlers.filter(c => c.pattern!='HeadlessChrome').map(c => c.pattern).join('|');
// Create and return the compiled regex pattern
return new RegExp(combinedPattern);
}
const botPattern = createBotPattern(crawlers);
2024-01-26 16:59:10 +08:00
router.get('/:room', gameRoomHandler);
router.get('/game/:plName', gameRoomHandler);
router.get('/game/:shareID/:name', gameRoomHandler);
router.get('/tutorial/:plName', gameRoomHandler);
async function gameRoomHandler(req, res, next) {
try {
if(!String(req.params.room).match(/^[A-Za-z0-9_-]+$/)) {
res.send('Invalid characters in room ID.');
return;
}
2024-01-26 16:59:10 +08:00
if(botPattern.test(req.headers['user-agent'])) {
let ogOutput = `<meta property="og:title" content="${Config.get('serverName')}" />`;
res.setHeader('Content-Type', 'text/html');
if(req.params.room) {
if(await ensureRoomIsLoaded(req.params.room)) {
const room = activeRooms.get(req.params.room);
let game = null;
if(room.state && room.state._meta && room.state._meta.activeState && room.state._meta.states && room.state._meta.states[room.state._meta.activeState.stateID])
game = room.state._meta.states[room.state._meta.activeState.stateID];
if(game) {
ogOutput += `<meta property="og:description" content="Come play the game ${game.name} with me!" />`;
ogOutput += `<meta property="og:image" content="${Config.get('externalURL')}/${game.image ? game.image.substr(1) : 'i/branding/android-512.png'}" />`;
} else {
ogOutput += `<meta property="og:description" content="Come play with me!" />`;
ogOutput += `<meta property="og:image" content="${Config.get('externalURL')}/i/branding/android-512.png" />`;
}
}
} else {
const share = await shareDetails(req.params.shareID || `PL:${req.url.split('/')[1]}:${req.params.plName}`);
if(share && req.url.split('/')[1] == 'tutorial') {
ogOutput += `<meta property="og:description" content="Come look at the tutorial ${share.name}!" />`;
ogOutput += `<meta property="og:image" content="${Config.get('externalURL')}/${share.image ? share.image.substr(1) : 'i/branding/android-512.png'}" />`;
} else if(share) {
ogOutput += `<meta property="og:description" content="Come play the game ${share.name} with your friends!" />`;
ogOutput += `<meta property="og:image" content="${Config.get('externalURL')}/${share.image ? share.image.substr(1) : 'i/branding/android-512.png'}" />`;
2024-01-26 16:59:10 +08:00
} else {
ogOutput += `<meta property="og:description" content="Come play with your friends!" />`;
2024-01-26 16:59:10 +08:00
ogOutput += `<meta property="og:image" content="${Config.get('externalURL')}/i/branding/android-512.png" />`;
}
2023-10-04 03:30:25 +08:00
}
ogOutput += `<p>Your browser identifies as a bot and therefor only receives metadata. Please use a different browser and/or <a href="https://github.com/ArnoldSmith86/virtualtabletop/issues/new">open an issue on GitHub</a>.</p>`;
res.send(ogOutput);
} else {
res.setHeader('Content-Type', 'text/html');
if(req.headers['accept-encoding'] && req.headers['accept-encoding'].match(/\bgzip\b/)) {
res.setHeader('Content-Encoding', 'gzip');
res.send(result.gzipped);
2023-10-04 03:30:25 +08:00
} else {
res.send(result.min);
2023-10-04 03:30:25 +08:00
}
}
} catch(e) {
next(e);
2024-01-26 16:59:10 +08:00
}
}
2022-12-30 17:46:55 +08:00
router.get('/createTempState/:room', function(req, res, next) {
ensureRoomIsLoaded(req.params.room).then(async function(isLoaded) {
if(isLoaded)
res.send(await activeRooms.get(req.params.room).createTempState());
}).catch(next);
});
router.put('/createTempState/:room/:tempID', bodyParser.raw({ limit: '500mb' }), function(req, res, next) {
ensureRoomIsLoaded(req.params.room).then(async function(isLoaded) {
if(isLoaded && req.params.tempID.match(/^[a-z0-9]{8}$/))
res.send(await activeRooms.get(req.params.room).createTempState(req.params.tempID, req.body));
}).catch(next);
});
2023-09-20 22:23:00 +08:00
router.put('/asset/:link', async function(req, res) {
try {
const content = Buffer.from(await (await fetch(req.params.link)).arrayBuffer());
const filename = `/${CRC32.buf(content)}_${content.length}`;
if(!Config.resolveAsset(filename.substr(1)))
fs.writeFileSync(assetsdir + filename, content);
res.send(`/assets${filename}`);
} catch(e) {
res.status(404).send('Downloading external asset failed.');
}
2023-09-20 22:23:00 +08:00
});
router.put('/asset', bodyParser.raw({ limit: '10mb' }), function(req, res) {
const filename = `/${CRC32.buf(req.body)}_${req.body.length}`;
2022-12-30 17:46:55 +08:00
if(!Config.resolveAsset(filename.substr(1)))
fs.writeFileSync(assetsdir + filename, req.body);
2021-12-02 03:48:03 +08:00
res.send(`/assets${filename}`);
});
router.put('/addState/:room/:id/:type/:name/:addAsVariant?', bodyParser.raw({ limit: '500mb' }), async function(req, res, next) {
2022-12-30 17:46:55 +08:00
if(!validateInput(res, next, [ req.params.id, req.params.addAsVariant ])) return;
ensureRoomIsLoaded(req.params.room).then(function(isLoaded) {
if(isLoaded) {
activeRooms.get(req.params.room).addState(req.params.id, req.params.type, req.body, req.params.name, req.params.addAsVariant).then(function() {
res.send('OK');
}).catch(next);
}
}).catch(next);
});
2023-11-03 23:58:43 +08:00
router.get('/saveCurrentState/:room/:mode/:name', async function(req, res, next) {
if(!validateInput(res, next, [ req.params.mode ])) return;
ensureRoomIsLoaded(req.params.room).then(function(isLoaded) {
if(isLoaded) {
activeRooms.get(req.params.room).saveCurrentState(req.params.mode, req.params.name);
res.send('OK');
}
}).catch(next);
});
router.put('/moveServer/:room/:returnServer/:returnState', bodyParser.raw({ limit: '500mb' }), async function(req, res, next) {
ensureRoomIsLoaded(req.params.room).then(function(isLoaded) {
if(isLoaded) {
activeRooms.get(req.params.room).receiveState(req.body, req.params.returnServer, req.params.returnState).then(function() {
res.send('OK');
}).catch(next);
}
}).catch(next);
});
router.put('/clientError', bodyParser.json({ limit: '50mb' }), function(req, res, next) {
if(typeof req.body == 'object') {
const errorID = Math.random().toString(36).substring(2, 10);
fs.writeFileSync(savedir + '/errors/' + errorID + '.json', JSON.stringify(req.body, null, ' '));
Logging.log(`ERROR: Client error ${errorID}: ${req.body.message}`);
res.send(errorID);
} else {
res.send('not a valid JSON object');
}
});
router.use(Logging.userErrorHandler);
router.use(Logging.errorHandler);
server.listen(Config.get('port'), function() {
2021-04-14 21:13:16 +08:00
Logging.log(`Listening on ${server.address().port}`);
});
});
const activeRooms = new Map();
const ws = new WebSocket(server, serverStart, function(connection, { playerName, roomID }) {
ensureRoomIsLoaded(roomID).then(function(isLoaded) {
if(isLoaded)
activeRooms.get(roomID).addPlayer(new Player(connection, playerName, activeRooms.get(roomID)));
}).catch(e=>Logging.handleGenericException(`player ${playerName} connected to room ${roomID}`, e));
});
2020-10-30 22:15:51 +08:00
2021-01-28 01:21:41 +08:00
autosaveRooms();
['exit', 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'SIGTERM'].forEach((eventType) => {
2020-10-30 22:15:51 +08:00
process.on(eventType, function() {
for(const [ _, room ] of activeRooms)
room.unload();
2022-12-30 17:46:55 +08:00
Statistics.writeToFilesystem();
if(eventType != 'exit')
process.exit();
2020-10-30 22:15:51 +08:00
});
});