'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 logIn(msg){ console.log(`in msg:${JSON.stringify(msg)}.........`); } function logOut(msg){ console.log(`out msg:${JSON.stringify(msg)}.........`); } function logPeers() { console.log(`Peers: ${Object.keys(gun._.opt.peers).join(', ')}`); } function logData() { console.log(`In Memory: ${JSON.stringify(gun._.graph)}`); } //gun._.on('in', logIn); //gun._.on('out', logOut); //setInterval(logPeers, 5000); //Log peer list every 5 secs //setInterval(logData, 20000); //Log gun graph every 20 secs 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; } async function logAllMeta() { const db = await dbInit; try { const querySql = ` SELECT gameId, lightName, darkName, moves, status, timestamp FROM games ORDER BY timestamp DESC `; for await (const row of db.eachAsync(querySql)) { console.log(JSON.stringify(pruneEmpty(row))); } } catch(err) { logDbError('logAllMeta')(err); } } 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)); } //logAllMeta(); 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 board, modified FROM games WHERE gameId = $gameId `; const result = await (await dbInit).getAsync(querySql, { $gameId: gameId }); if (!result) { res.status(404).json({ message: 'unknown game ID' }); return; } if (afterTime === undefined || result.modified > afterTime) { const parsed = (result.board === '') ? null : JSON.parse(result.board); res.json({ board: parsed, modified: result.modified }); return; } if (await Promise.race([gameUpdate, pollTimeout]) === 'timeout') { res.status(204).json({ retry: true }); return; } } } catch(err) { internalErrorJson(err, res); } } async function getMetaHandler(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 metaUpdate = waitForGameUpdate(gameId).then(() => 'update'); const querySql = ` SELECT lightName, darkName, moves, status, timestamp, modified FROM games WHERE gameId = $gameId `; const result = await (await dbInit).getAsync(querySql, { $gameId: gameId }); if (!result) { res.status(404).json({ message: 'unknown game ID' }); return; } if (afterTime === undefined || result.modified > afterTime) { res.json(pruneEmpty(result)); return; } if (await Promise.race([metaUpdate, pollTimeout]) === 'timeout') { res.status(204).json({ retry: true }); return; } } } catch (err) { internalErrorJson(err, res); } } app.get('/pacosako/api/games/poll/:afterTime', getGameListHandler); app.get('/pacosako/api/games', getGameListHandler); app.get('/pacosako/api/game/:gameId/poll/:afterTime', getGameHandler); app.get('/pacosako/api/game/:gameId', getGameHandler); app.get('/pacosako/api/meta/:gameId/poll/:afterTime', getMetaHandler); app.get('/pacosako/api/meta/:gameId', getMetaHandler); /* TODO: Add APIs for posting updates. */ app.use('/pacosako/api/posttest', express.json()); app.post('/pacosako/api/posttest', async function(req, res, next) { try { res.json(req.body); } catch (err) { internalErrorJson(err, res); } }); app.use('/pacosako/api', express.static('public')); app.use('/gun', Gun.serve(__dirname)); config.server.listen(config.port);