422 lines
11 KiB
JavaScript
422 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
var fs = require('fs');
|
|
var sqlite3 = require('./sqlite3-promises');
|
|
var express = require('express');
|
|
|
|
const POLLING_TIMEOUT = 60000/*ms*/;
|
|
|
|
let nextMonotonicTime = 1;
|
|
|
|
function monotonicTime() {
|
|
const now = +new Date();
|
|
const mono = (now >= nextMonotonicTime) ? now : nextMonotonicTime;
|
|
nextMonotonicTime = mono + 1;
|
|
return mono;
|
|
}
|
|
|
|
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)
|
|
`);
|
|
|
|
const maxModified = await db.getAsync(`
|
|
SELECT MAX(modified) as result FROM games
|
|
`).catch(logDbError('maxModified'));
|
|
|
|
/* Just in case the system clock moved backward since the last record was written. */
|
|
if (maxModified && maxModified.result) {
|
|
nextMonotonicTime = maxModified.result + 1;
|
|
}
|
|
|
|
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` };
|
|
}
|
|
|
|
function catchExceptionsJson(wrapped) {
|
|
const context = this;
|
|
return function(req, res, next) {
|
|
function internalErrorJson(err) {
|
|
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' });
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = wrapped.call(context, req, res, next);
|
|
if (result instanceof Promise) {
|
|
return result.catch(internalErrorJson);
|
|
}
|
|
} catch (err) {
|
|
internalErrorJson(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getGameListHandler(req, res, next) {
|
|
res.set('Cache-Control', 'no-store');
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getGameHandler(req, res, next) {
|
|
res.set('Cache-Control', 'no-store');
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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');
|
|
|
|
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 time = monotonicTime();
|
|
|
|
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 transactionP;
|
|
db.serialize(() => {
|
|
/* Important: We need to start all these queries without waiting in between. */
|
|
/* Otherwise db.serialize() will not have the desired effect. */
|
|
const beginP = db.execAsync(`BEGIN TRANSACTION`);
|
|
const insertP = db.runAsync(insertSql, params);
|
|
const selectP = db.getAsync(selectSql, { $gameId: gameId });
|
|
const commitP = db.execAsync(`COMMIT TRANSACTION`);
|
|
transactionP = Promise.all([beginP, insertP, commitP]).then(() => selectP);
|
|
});
|
|
/* Now wait for all the queries to finish. */
|
|
const result = await transactionP;
|
|
|
|
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 });
|
|
}
|
|
|
|
const app = express();
|
|
|
|
app.get('/pacosako/api/games/poll/:afterTime', catchExceptionsJson(getGameListHandler));
|
|
app.get('/pacosako/api/games', catchExceptionsJson(getGameListHandler));
|
|
|
|
app.get('/pacosako/api/:type(game|meta)/:gameId/poll/:afterTime', catchExceptionsJson(getGameHandler));
|
|
app.get('/pacosako/api/:type(game|meta)/:gameId', catchExceptionsJson(getGameHandler));
|
|
app.post('/pacosako/api/:type(game|meta)/:gameId', express.json(), catchExceptionsJson(postGameHandler));
|
|
|
|
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);
|
|
}
|
|
|
|
config.server.listen(config.port);
|