'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);