From 3653f3db9974bb85c1dbc925d1ca48185c324c1a Mon Sep 17 00:00:00 2001 From: Jesse McDonald Date: Mon, 27 Apr 2020 17:55:28 -0500 Subject: [PATCH] add a REST API to submit new game data via POST --- index.js | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 162 insertions(+), 10 deletions(-) diff --git a/index.js b/index.js index 6a34739..0d71dea 100644 --- a/index.js +++ b/index.js @@ -461,25 +461,177 @@ async function getMetaHandler(req, res, next) { } } +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) { + console.log('invalid type or missing modified property'); + return null; + } + + for (const key in body) { + if (key in updateTemplate === false) { + console.log('extra key', key); + return null; + } + if (updateTemplate[key](body[key]) === false) { + console.log('invalid value', body[key], 'for key', key); + return null; + } + } + } catch(err) { + console.log('exception', 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); + + console.log('update for', gameId, 'at time', time, req.body, !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/game/:gameId/poll/:afterTime', getGameHandler); app.get('/pacosako/api/game/:gameId', getGameHandler); +app.post('/pacosako/api/game/:gameId', express.json(), postGameHandler); 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));