550 lines
16 KiB
JavaScript
550 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
var fs = require('fs');
|
|
var Gun = require('gun');
|
|
var SEA = require('gun/sea');
|
|
var NTS = require('gun/nts');
|
|
var rtc = require('gun/lib/webrtc');
|
|
var sqlite3 = require('./sqlite3-promises');
|
|
var express = require('express');
|
|
|
|
require('./gun-with-cancel')(Gun);
|
|
|
|
const POLLING_TIMEOUT = 60000/*ms*/;
|
|
|
|
const app = express();
|
|
|
|
var config = { port: process.env.OPENSHIFT_NODEJS_PORT || process.env.VCAP_APP_PORT || process.env.PORT || process.argv[2] || 8765 };
|
|
|
|
if (process.env.HTTPS_KEY) {
|
|
config.key = fs.readFileSync(process.env.HTTPS_KEY);
|
|
config.cert = fs.readFileSync(process.env.HTTPS_CERT);
|
|
config.server = require('https').createServer(config, app);
|
|
} else {
|
|
config.server = require('http').createServer(app);
|
|
}
|
|
|
|
var gun = Gun({
|
|
web: config.server,
|
|
peers: ['https://jessemcdonald.info/gun'],
|
|
});
|
|
console.log('Relay peer started on port ' + config.port + ' with /gun');
|
|
|
|
var appendJournal = (function() {
|
|
let lastJournalText = null;
|
|
return function appendJournal(text) {
|
|
if (text !== lastJournalText) {
|
|
fs.appendFileSync('journal/journal.txt', text + '\n');
|
|
lastJournalText = text;
|
|
}
|
|
};
|
|
})();
|
|
|
|
function logDbError(label) {
|
|
return function(err) {
|
|
console.error(label + ':', ((err && err.message) || err));
|
|
};
|
|
}
|
|
|
|
const dbInit = (async function dbInit() {
|
|
const db = await sqlite3.openAsync('./pacosako.db');
|
|
|
|
try {
|
|
await db.runAsync(`VACUUM`);
|
|
} catch(err) {
|
|
logDbError('vacuum')(err);
|
|
}
|
|
|
|
await db.runAsync(`
|
|
CREATE TABLE IF NOT EXISTS games (
|
|
gameId TEXT PRIMARY KEY,
|
|
lightName TEXT NOT NULL,
|
|
darkName TEXT NOT NULL,
|
|
moves INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
timestamp INTEGER NOT NULL,
|
|
board TEXT NOT NULL,
|
|
added INTEGER NOT NULL,
|
|
modified INTEGER NOT NULL
|
|
)
|
|
`);
|
|
|
|
await db.runAsync(`
|
|
CREATE INDEX IF NOT EXISTS games_timestamp ON games(timestamp)
|
|
`);
|
|
|
|
await db.runAsync(`
|
|
CREATE INDEX IF NOT EXISTS games_modified ON games(modified)
|
|
`);
|
|
|
|
console.log('Connected to the SQLite database.');
|
|
|
|
return db;
|
|
})().catch(logDbError('dbInit'));
|
|
|
|
function pruneEmpty(obj) {
|
|
const copy = {};
|
|
for (const key in obj) {
|
|
const val = obj[key];
|
|
if (val !== undefined && val !== null && val !== '') {
|
|
copy[key] = obj[key];
|
|
}
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
let waitingAnyGame = null;
|
|
const waitingGames = {};
|
|
|
|
function signalGameUpdate(gameId) {
|
|
const promise = waitingGames[gameId];
|
|
if (promise) {
|
|
delete waitingGames[gameId];
|
|
promise.resolve();
|
|
}
|
|
const anyPromise = waitingAnyGame;
|
|
if (anyPromise) {
|
|
waitingAnyGame = null;
|
|
anyPromise.resolve();
|
|
}
|
|
}
|
|
|
|
function waitForGameUpdate(gameId) {
|
|
let promise = waitingGames[gameId];
|
|
if (promise === undefined) {
|
|
let resolve;
|
|
promise = waitingGames[gameId] = new Promise((res) => { resolve = res; });
|
|
promise.resolve = resolve;
|
|
}
|
|
return promise;
|
|
}
|
|
|
|
function waitForAnyGameUpdate() {
|
|
if (waitingAnyGame === null) {
|
|
let resolve;
|
|
waitingAnyGame = new Promise((res) => { resolve = res; });
|
|
waitingAnyGame.resolve = resolve;
|
|
}
|
|
return waitingAnyGame;
|
|
}
|
|
|
|
function waitFor(duration) {
|
|
return new Promise((resolve, reject) => setTimeout(() => { resolve(); }, duration));
|
|
}
|
|
|
|
function checkString(value, label, dflt) {
|
|
try {
|
|
if (arguments.length >= 3 && (value === undefined || value === null)) {
|
|
return dflt;
|
|
} else if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
} catch (err) {}
|
|
throw { message: `${label || 'value'} should be a string` };
|
|
}
|
|
|
|
function checkInteger(value, label, dflt) {
|
|
try {
|
|
if (arguments.length >= 3 && (value === undefined || value === null)) {
|
|
return dflt;
|
|
} else if (Number.isInteger(value)) {
|
|
return value;
|
|
}
|
|
} catch(err) {}
|
|
throw { message: `${label || 'value'} should be an integer` };
|
|
}
|
|
|
|
async function updateMeta(gameId, meta, time) {
|
|
if (arguments.length < 3) {
|
|
time = +new Date();
|
|
}
|
|
|
|
const db = await dbInit;
|
|
|
|
const insertSql = `
|
|
INSERT INTO games (gameId, lightName, darkName, moves, status, timestamp, board, added, modified)
|
|
VALUES ($gameId, $lightName, $darkName, $moves, $status, $timestamp, '', $time, $time)
|
|
ON CONFLICT (gameId) DO UPDATE
|
|
SET lightName = $lightName, darkName = $darkName, moves = $moves,
|
|
status = $status, timestamp = $timestamp, modified = $time
|
|
WHERE (lightName <> $lightName OR darkName <> $darkName OR moves <> $moves OR
|
|
status <> $status OR timestamp <> $timestamp)
|
|
AND modified < $time
|
|
`;
|
|
await db.runAsync(insertSql, {
|
|
$gameId: gameId,
|
|
$lightName: checkString(meta.lightName, 'meta.lightName', ''),
|
|
$darkName: checkString(meta.darkName, 'meta.darkName', ''),
|
|
$moves: checkInteger(meta.moves, 'meta.moves', 0),
|
|
$status: checkString(meta.status, 'meta.status', ''),
|
|
$timestamp: checkInteger(meta.timestamp, 'meta.timestamp', 0),
|
|
$time: time,
|
|
});
|
|
|
|
signalGameUpdate(gameId);
|
|
|
|
return db;
|
|
}
|
|
|
|
async function updateGame(gameId, moves, time) {
|
|
if (arguments.length < 3) {
|
|
time = +new Date();
|
|
}
|
|
|
|
const db = await dbInit;
|
|
|
|
const insertSql = `
|
|
INSERT INTO games (gameId, lightName, darkName, moves, status, timestamp, board, added, modified)
|
|
VALUES ($gameId, '', '', 0, '', 0, '', $time, $time)
|
|
ON CONFLICT (gameId) DO UPDATE
|
|
SET board = $board, modified = $time
|
|
WHERE (board <> $board) AND modified < $time
|
|
`;
|
|
await db.runAsync(insertSql, {
|
|
$gameId: gameId,
|
|
$board: JSON.stringify(moves),
|
|
$time: time,
|
|
});
|
|
|
|
signalGameUpdate(gameId);
|
|
|
|
return db;
|
|
}
|
|
|
|
const PacoSakoUUID = 'b425b812-6bdb-11ea-9414-6f946662bac3';
|
|
|
|
let cancellers = {};
|
|
|
|
gun.get(PacoSakoUUID + '/meta').on(function(meta) {
|
|
for (const gameId in meta) { /* use of 'in' here is deliberate */
|
|
/* 'gameId' may include extra GUN fields like '_' */
|
|
if (gameId.match(/^[0-9a-f]{16}$/)) {
|
|
if (!Gun.obj.is(meta[gameId])) {
|
|
appendJournal(JSON.stringify({ meta: { [gameId]: null } }));
|
|
if (gameId in cancellers) {
|
|
cancellers[gameId]();
|
|
delete cancellers[gameId];
|
|
}
|
|
} else if (!(gameId in cancellers)) {
|
|
let cancelMeta = gun.get(meta[gameId]).onWithCancel(function(data) {
|
|
updateMeta(gameId, data).catch(logDbError('updateMeta'));
|
|
let text;
|
|
try {
|
|
let clean = null;
|
|
if (data !== null) {
|
|
clean = {};
|
|
for (const k of Object.keys(data).sort()) {
|
|
if (k !== '_') {
|
|
clean[k] = data[k];
|
|
}
|
|
}
|
|
}
|
|
text = JSON.stringify({ meta: { [gameId]: clean } });
|
|
} catch(err) {}
|
|
if (text) {
|
|
appendJournal(text);
|
|
}
|
|
});
|
|
|
|
let cancelGame = gun.get(PacoSakoUUID + '/game/' + gameId).onWithCancel(function(data) {
|
|
if (data && typeof data.board === 'string') {
|
|
let text;
|
|
try {
|
|
const parsed = JSON.parse(data.board);
|
|
updateGame(gameId, parsed).catch(logDbError('updateGame'));
|
|
text = JSON.stringify({ game: { [gameId]: { board: parsed } } });
|
|
} catch(err) {}
|
|
if (text) {
|
|
appendJournal(text);
|
|
}
|
|
}
|
|
});
|
|
|
|
cancellers[gameId] = function() { cancelMeta(); cancelGame(); };
|
|
}
|
|
}
|
|
}
|
|
}, { change: true });
|
|
|
|
function internalErrorJson(err, res) {
|
|
res.status(500);
|
|
if (err && 'message' in err) {
|
|
console.error(err.message);
|
|
res.json({ message: 'internal error: ' + err.message });
|
|
} else {
|
|
console.error(err);
|
|
res.json({ message: 'internal error' });
|
|
}
|
|
}
|
|
|
|
async function getGameListHandler(req, res, next) {
|
|
res.set('Cache-Control', 'no-store');
|
|
try {
|
|
const afterTime = req.params.afterTime;
|
|
|
|
if (afterTime !== undefined && !afterTime.match(/^\d+$/)) {
|
|
res.status(400).json({ message: 'malformed time' });
|
|
return;
|
|
}
|
|
|
|
const pollTimeout = waitFor(POLLING_TIMEOUT).then(() => 'timeout').catch(()=>{});
|
|
|
|
while (true) {
|
|
/* Save the async promise _before_ the query so we don't miss any updates while suspended. */
|
|
const gameUpdate = waitForAnyGameUpdate().then(() => 'update');
|
|
|
|
const cutoff = (+new Date()) - (2 * 7 * 24 * 3600 * 1000); /* 2 weeks ago */
|
|
const whereClause = (afterTime === undefined) ? `true` : `modified > $afterTime`;
|
|
const querySql = `
|
|
SELECT gameId, lightName, darkName, moves, status, timestamp, modified
|
|
FROM games WHERE timestamp >= $cutoff AND ${whereClause} ORDER BY timestamp DESC
|
|
LIMIT 1000
|
|
`;
|
|
const results = await (await dbInit).allAsync(querySql, {
|
|
$afterTime: checkInteger(Number(afterTime), 'afterTime', 0),
|
|
$cutoff: cutoff,
|
|
});
|
|
|
|
if (afterTime === undefined || results.length > 0) {
|
|
let lastModified = afterTime || 0;
|
|
for (const result of results) {
|
|
if (result.modified > lastModified) {
|
|
lastModified = result.modified;
|
|
}
|
|
}
|
|
|
|
res.json({ games: results.map(pruneEmpty), modified: lastModified });
|
|
return;
|
|
}
|
|
|
|
if (await Promise.race([gameUpdate, pollTimeout]) === 'timeout') {
|
|
res.status(204).json({ retry: true });
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
internalErrorJson(err, res);
|
|
}
|
|
}
|
|
|
|
async function getGameHandler(req, res, next) {
|
|
res.set('Cache-Control', 'no-store');
|
|
try {
|
|
const gameId = req.params.gameId;
|
|
const afterTime = req.params.afterTime;
|
|
|
|
if (!gameId.match(/^[0-9a-f]{16}$/)) {
|
|
res.status(400).json({ message: 'malformed game ID' });
|
|
return;
|
|
}
|
|
|
|
if (afterTime !== undefined && !afterTime.match(/^\d+$/)) {
|
|
res.status(400).json({ message: 'malformed time' });
|
|
return;
|
|
}
|
|
|
|
const pollTimeout = waitFor(POLLING_TIMEOUT).then(() => 'timeout').catch(()=>{});
|
|
|
|
while (true) {
|
|
/* Save the async promise _before_ the query so we don't miss any updates while suspended. */
|
|
const gameUpdate = waitForGameUpdate(gameId).then(() => 'update');
|
|
|
|
const querySql = `
|
|
SELECT lightName, darkName, moves, status, timestamp, board, modified FROM games
|
|
WHERE gameId = $gameId
|
|
`;
|
|
const result = await (await dbInit).getAsync(querySql, { $gameId: gameId });
|
|
|
|
if (result && (afterTime === undefined || result.modified > afterTime)) {
|
|
if (req.params.type === 'meta') {
|
|
delete result.board;
|
|
} else {
|
|
result.board = (result.board === '') ? null : JSON.parse(result.board);
|
|
}
|
|
res.json(pruneEmpty(result));
|
|
return;
|
|
}
|
|
|
|
if (afterTime === undefined) {
|
|
res.status(404).json({ message: 'unknown game ID' });
|
|
return;
|
|
}
|
|
|
|
if (await Promise.race([gameUpdate, pollTimeout]) === 'timeout') {
|
|
res.status(204).json({ retry: true });
|
|
return;
|
|
}
|
|
}
|
|
} catch(err) {
|
|
internalErrorJson(err, res);
|
|
}
|
|
}
|
|
|
|
const updateTemplate = {
|
|
lightName(x) { return typeof x === 'string'; },
|
|
darkName(x) { return typeof x === 'string'; },
|
|
moves(x) { return Number.isInteger(x); },
|
|
status(x) { return typeof x === 'string'; },
|
|
timestamp(x) { return Number.isInteger(x); },
|
|
board(x) { return true; },
|
|
modified(x) { return Number.isInteger(x); },
|
|
};
|
|
|
|
function validateUpdate(body) {
|
|
try {
|
|
if (typeof body !== 'object' || 'modified' in body === false) {
|
|
return null;
|
|
}
|
|
|
|
for (const key in body) {
|
|
if (key in updateTemplate === false) {
|
|
return null;
|
|
}
|
|
if (updateTemplate[key](body[key]) === false) {
|
|
return null;
|
|
}
|
|
}
|
|
} catch(err) {
|
|
return null;
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
async function postGameHandler(req, res, next) {
|
|
res.set('Cache-Control', 'no-store');
|
|
try {
|
|
const time = +new Date();
|
|
const gameId = req.params.gameId;
|
|
const body = validateUpdate(req.body);
|
|
|
|
if (!gameId.match(/^[0-9a-f]{16}$/)) {
|
|
res.status(400).json({ message: 'malformed game ID' });
|
|
return;
|
|
}
|
|
|
|
if (!body) {
|
|
res.status(400).json({ message: 'invalid request' });
|
|
return;
|
|
}
|
|
|
|
const params = {
|
|
$gameId: gameId,
|
|
$lightName: '',
|
|
$darkName: '',
|
|
$moves: 0,
|
|
$status: '',
|
|
$timestamp: 0,
|
|
$board: '',
|
|
$modified: body.modified,
|
|
$time: time,
|
|
};
|
|
|
|
if (params.$modified >= params.$time) {
|
|
res.status(400).json({ message: 'invalid modification time' });
|
|
return;
|
|
}
|
|
|
|
let setClause = '';
|
|
let whereClause = '';
|
|
let hasBoard = false;
|
|
let hasMeta = false;
|
|
|
|
for (const key in body) {
|
|
if (key !== 'modified') {
|
|
if (key in updateTemplate === false) {
|
|
throw { message: 'internal error' };
|
|
}
|
|
if (key === 'board') {
|
|
params['$' + key] = JSON.stringify(body[key]);
|
|
hasBoard = true;
|
|
} else {
|
|
params['$' + key] = body[key];
|
|
hasMeta = true;
|
|
}
|
|
if (setClause !== '') {
|
|
setClause += ', ';
|
|
whereClause += ' OR ';
|
|
}
|
|
setClause += key + ' = $' + key;
|
|
whereClause += key + ' <> $' + key;
|
|
}
|
|
}
|
|
|
|
if (setClause === '') {
|
|
res.status(400).json({ message: 'empty update' });
|
|
return;
|
|
}
|
|
|
|
const db = await dbInit;
|
|
|
|
const insertSql = `
|
|
INSERT INTO games (gameId, lightName, darkName, moves, status, timestamp, board, added, modified)
|
|
VALUES ($gameId, $lightName, $darkName, $moves, $status, $timestamp, $board, $time, $time)
|
|
ON CONFLICT (gameId) DO UPDATE SET ${setClause}, modified = $time
|
|
WHERE (${whereClause}) AND modified = $modified
|
|
`;
|
|
const selectSql = `SELECT * FROM games WHERE gameId = $gameId`
|
|
let beginP, insertP, selectP, commitP;
|
|
db.serialize(() => {
|
|
/* Important: We need to start all these queries without waiting in between. */
|
|
/* Otherwise db.serialize() will not have the desired effect. */
|
|
beginP = db.execAsync(`BEGIN TRANSACTION`);
|
|
insertP = db.runAsync(insertSql, params);
|
|
selectP = db.getAsync(selectSql, { $gameId: gameId });
|
|
commitP = db.execAsync(`COMMIT TRANSACTION`);
|
|
});
|
|
/* Now wait for all the queries to finish. */
|
|
await Promise.all([beginP, insertP, commitP]);
|
|
const result = await selectP;
|
|
|
|
if (!result || result.modified !== params.$time) {
|
|
res.status(409).json({ message: 'update failed', modified: (result || {}).modified });
|
|
return;
|
|
}
|
|
|
|
signalGameUpdate(gameId);
|
|
|
|
res.json({ success: true, modified: result.modified });
|
|
|
|
(async () => {
|
|
if (hasBoard) {
|
|
gun.get(PacoSakoUUID + '/game/' + gameId).get('board').put(result.board);
|
|
}
|
|
|
|
if (hasMeta) {
|
|
const meta = {
|
|
lightName: result.lightName,
|
|
darkName: result.darkName,
|
|
moves: result.moves,
|
|
timestamp: result.timestamp,
|
|
status: (result.status === '') ? null : result.status,
|
|
};
|
|
|
|
const metaRef = gun.get(PacoSakoUUID + '/meta/' + gameId).put(meta);
|
|
|
|
if (meta.lightName !== '' || meta.darkName !== '' || meta.moves !== 0) {
|
|
gun.get(PacoSakoUUID + '/meta').get(gameId).put(metaRef);
|
|
} else {
|
|
gun.get(PacoSakoUUID + '/meta').get(gameId).put(null);
|
|
}
|
|
}
|
|
})().catch(console.error);
|
|
} catch (err) {
|
|
internalErrorJson(err, res);
|
|
}
|
|
}
|
|
|
|
app.get('/pacosako/api/games/poll/:afterTime', getGameListHandler);
|
|
app.get('/pacosako/api/games', getGameListHandler);
|
|
|
|
app.get('/pacosako/api/:type(game|meta)/:gameId/poll/:afterTime', getGameHandler);
|
|
app.get('/pacosako/api/:type(game|meta)/:gameId', getGameHandler);
|
|
app.post('/pacosako/api/:type(game|meta)/:gameId', express.json(), postGameHandler);
|
|
|
|
app.use('/pacosako/api', express.static('public'));
|
|
|
|
app.use('/gun', Gun.serve(__dirname));
|
|
|
|
config.server.listen(config.port);
|