]> git.cworth.org Git - lmno-server/blob - lmno.js
admin: Fix admin page to correctly show active/idle games and players
[lmno-server] / lmno.js
1 const express = require("express");
2 const cors = require("cors");
3 const body_parser = require("body-parser");
4 const session = require("express-session");
5 const bcrypt = require("bcrypt");
6 const path = require("path");
7 const nunjucks = require("nunjucks");
8
9 try {
10   var lmno_config = require("./lmno-config.json");
11 } catch (err) {
12   config_usage();
13   process.exit(1);
14 }
15
16 function config_usage() {
17   console.log(`Error: Refusing to run without configuration.
18
19 Please create a file named lmno-config.json that looks as follows:
20
21 {
22   "session_secret": "<this should be a long string of true-random characters>",
23   "users": {
24     "username": "<username>",
25     "password_hash_bcrypt": "<password_hash_made_by_bcrypt>"
26   }
27 }
28
29 Note: Of course, change all of <these-parts> to actual values desired.
30
31 The "node lmno-passwd.js" command can help generate password hashes.`);
32 }
33
34 const app = express();
35
36 /* This 'trust proxy' option, (and, really? a space in an option
37  * name?!)  means that express will grab hostname and IP values from
38  * the X-Forwarded-* header fields. We need that so that our games
39  * will display a proper hostname of https://lmno.games/WXYZ instead
40  * of http://localhost/QFBL which will obviously not be a helpful
41  * thing to share around.
42  */
43 app.set('trust proxy', true);
44 app.use(cors());
45 app.use(body_parser.urlencoded({ extended: false }));
46 app.use(body_parser.json());
47 app.use(session({
48   secret: lmno_config.session_secret,
49   resave: false,
50   saveUninitialized: false
51 }));
52
53 const njx = nunjucks.configure("templates", {
54   autoescape: true,
55   express: app
56 });
57
58 njx.addFilter('active', function(list) {
59   if (list)
60     return list.filter(e => e.active === true);
61   else
62     return null;
63 });
64
65 njx.addFilter('idle', function(list) {
66   if (list)
67     return list.filter(e => e.active === false);
68   else
69     return null;
70 });
71
72 /* Load each of our game mini-apps.
73  *
74  * Each "engine" we load here must have a property .Game on the
75  * exports object that should be a class that extends the common base
76  * class Game.
77  *
78  * In turn, each engine's Game must have the following properties:
79  *
80  *     .meta:   An object with .name and .identifier properties.
81  *
82  *              Here, .name is a string giving a human-readable name
83  *              for the game, such as "Tic Tac Toe" while .identifier
84  *              is the short, single-word, all-lowercase identifier
85  *              that is used in the path of the URL, such as
86  *              "tictactoe".
87  *
88  *     .router: An express Router object
89  *
90  *              Any game-specific routes should already be on the
91  *              router. Then, LMNO will add common routes including:
92  *
93  *                 /        Serves <identifier>-game.html template
94  *
95  *                 /player  Allows client to set name or team
96  *
97  *                 /events  Serves a stream of events. Game can override
98  *                          the handle_events method, call super() first,
99  *                          and then have code to add custom events.
100  *
101  *                 /moves   Receives move data from clients. This route
102  *                          is only added if the Game class has an
103  *                          add_move method.
104  */
105 const engines = {
106   empires: require("./empires").Game,
107   tictactoe: require("./tictactoe").Game,
108   scribe: require("./scribe").Game,
109   empathy: require("./empathy").Game
110 };
111
112 class LMNO {
113   constructor() {
114     this.games = {};
115   }
116
117   generate_id() {
118     /* Note: The copy from Array(4) to [...Array(4)] is necessary so
119      * that map() will actually work, (which it doesn't on an array
120      * from Array(N) which is in this strange state of having "empty"
121      * items rather than "undefined" as we get after [...Array(4)] */
122     return [...Array(4)].map(() => LMNO.letters.charAt(Math.floor(Math.random() * LMNO.letters.length))).join('');
123   }
124
125   create_game(engine_name) {
126     do {
127       var id = this.generate_id();
128     } while (id in this.games);
129
130     const engine = engines[engine_name];
131
132     const game = new engine(id);
133
134     this.games[id] = game;
135
136     return game;
137   }
138 }
139
140 /* Some letters we don't use in our IDs:
141  *
142  * 1. Vowels (AEIOU) to avoid accidentally spelling an unfortunate word
143  * 2. Lowercase letters (replace with corresponding capital on input)
144  * 3. N (replace with M on input)
145  * 4. B (replace with P on input)
146  * 5. F,X (replace with S on input)
147  */
148 LMNO.letters = "CCDDDGGGHHJKLLLLMMMMPPPPQRRRSSSTTTVVWWYYZ";
149
150 const lmno = new LMNO();
151
152 /* Force a game ID into a canonical form as described above. */
153 function lmno_canonize(id) {
154   /* Capitalize */
155   id = id.toUpperCase();
156
157   /* Replace unused letters with nearest phonetic match. */
158   id = id.replace(/N/g, 'M');
159   id = id.replace(/B/g, 'P');
160   id = id.replace(/F/g, 'S');
161   id = id.replace(/X/g, 'S');
162
163   /* Replace unused numbers nearest visual match. */
164   id = id.replace(/0/g, 'O');
165   id = id.replace(/1/g, 'I');
166   id = id.replace(/5/g, 'S');
167
168   return id;
169 }
170
171 app.post('/new/:game_engine', (request, response) =>  {
172   const game_engine = request.params.game_engine;
173   const game = lmno.create_game(game_engine);
174   response.send(JSON.stringify(game.id));
175 });
176
177 /* Redirect any requests to a game ID at the top-level.
178  *
179  * Specifically, after obtaining the game ID (from the path) we simply
180  * lookup the game engine for the corresponding game and then redirect
181  * to the engine- and game-specific path.
182  */
183 app.get('/[a-zA-Z0-9]{4}', (request, response) => {
184   const game_id = request.path.replace(/\//g, "");
185   const canon_id = lmno_canonize(game_id);
186
187   /* Redirect user to page with the canonical ID in it. */
188   if (game_id !== canon_id) {
189     response.redirect(301, `/${canon_id}/`);
190     return;
191   }
192
193   const game = lmno.games[game_id];
194   if (game === undefined) {
195       response.sendStatus(404);
196       return;
197   }
198   response.redirect(301, `/${game.meta.identifier}/${game.id}/`);
199 });
200
201 /* LMNO middleware to lookup the game. */
202 app.use('/:engine([^/]+)/:game_id([a-zA-Z0-9]{4})', (request, response, next) => {
203   const engine = request.params.engine;
204   const game_id = request.params.game_id;
205   const canon_id = lmno_canonize(game_id);
206
207   /* Redirect user to page with the canonical ID in it, also ensuring
208    * that the game ID is _always_ followed by a slash. */
209   const has_slash = new RegExp(`^/${engine}/${game_id}/`);
210   if (game_id !== canon_id ||
211       ! has_slash.test(request.originalUrl))
212   {
213     const old_path = new RegExp(`/${engine}/${game_id}/?`);
214     const new_path = `/${engine}/${canon_id}/`;
215     const new_url = request.originalUrl.replace(old_path, new_path);
216     response.redirect(301, new_url);
217     return;
218   }
219
220   /* See if there is any game with this ID. */
221   const game = lmno.games[game_id];
222   if (game === undefined) {
223     response.sendStatus(404);
224     return;
225   }
226
227   /* Stash the game onto the request to be used by the game-specific code. */
228   request.game = game;
229   next();
230 });
231
232 function auth_admin(request, response, next) {
233   /* If there is no user associated with this session, redirect to the login
234    * page (and set a "next" query parameter so we can come back here).
235    */
236   if (! request.session.user) {
237     response.redirect(302, "/login?next=" + request.path);
238     return;
239   }
240
241   /* If the user is logged in but not authorized to view the page then 
242    * we return that error. */
243   if (request.session.user.role !== "admin") {
244     response.status(401).send("Unauthorized");
245     return;
246   }
247   next();
248 }
249
250 app.get('/logout', (request, response) => {
251   request.session.user = undefined;
252   request.session.destroy();
253
254   response.send("You are now logged out.");
255 });
256
257 app.get('/login', (request, response) => {
258   if (request.session.user) {
259     response.send("Welcome, " + request.session.user + ".");
260     return;
261   }
262
263   response.render('login.html');
264 });
265
266 app.post('/login', async (request, response) => {
267   const username = request.body.username;
268   const password = request.body.password;
269   const user = lmno_config.users[username];
270   if (! user) {
271     response.sendStatus(404);
272     return;
273   }
274   const match = await bcrypt.compare(password, user.password_hash_bcrypt);
275   if (! match) {
276     response.sendStatus(404);
277     return;
278   }
279   request.session.user = { username: user.username, role: user.role };
280   response.sendStatus(200);
281   return;
282 });
283
284 /* API to set uer profile information */
285 app.put('/profile', (request, response) => {
286   const nickname = request.body.nickname;
287   if (nickname) {
288     request.session.nickname = nickname;
289     request.session.save();
290   }
291   response.send();
292 });
293
294 /* An admin page (only available to admin users, of course) */
295 app.get('/admin/', auth_admin, (request, response) => {
296   let active = [];
297   let idle = [];
298
299   for (let id in lmno.games) {
300     if (lmno.games[id].players.filter(p => p.active).length > 0)
301       active.push(lmno.games[id]);
302     else
303       idle.push(lmno.games[id]);
304   }
305   response.render('admin.html', { games: { active: active, idle: idle}});
306 });
307
308
309 /* Mount sub apps. only _after_ we have done all the middleware we need. */
310 for (let key in engines) {
311   const engine = engines[key];
312   const router = engine.router;
313
314   /* Add routes that are common to all games. */
315   router.get('/', (request, response) => {
316     const game = request.game;
317
318     if (! request.session.nickname) {
319       response.render('choose-nickname.html', {
320         game_name: game.meta.name,
321         options: game.meta.options
322       });
323     } else {
324       response.render(`${game.meta.identifier}-game.html`);
325     }
326   });
327
328   router.put('/player', (request, response) => {
329     const game = request.game;
330
331     game.handle_player(request, response);
332   });
333
334   router.get('/events', (request, response) => {
335     const game = request.game;
336
337     game.handle_events(request, response);
338   });
339
340   /* Further, add some routes conditionally depending on whether the
341    * engine provides specific, necessary methods for the routes. */
342
343   /* Note: We have to use hasOwnProperty here since the base Game
344    * class has a geeric add_move function, and we don't want that to
345    * have any influence on our decision. Only if the child has
346    * overridden that do we want to create a "/move" route. */
347   if (engine.prototype.hasOwnProperty("add_move")) {
348     router.post('/move', (request, response) => {
349       const game = request.game;
350       const move = request.body.move;
351       const player = game.players_by_session[request.session.id];
352
353       /* Reject move if there is no player for this session. */
354       if (! player) {
355         response.json({legal: false, message: "No valid player from session"});
356         return;
357       }
358
359       const result = game.add_move(player, move);
360
361       /* Take care of any generic post-move work. */
362       game.post_move(player, result);
363
364       /* Feed move response back to the client. */
365       response.json(result);
366
367       /* And only if legal, inform all clients. */
368       if (! result.legal)
369         return;
370
371       game.broadcast_move(move);
372     });
373   }
374
375   /* And mount the whole router at the path for the game. */
376   app.use(`/${engine.meta.identifier}/[a-zA-Z0-9]{4}/`, router);
377 }
378
379 app.listen(4000, function () {
380   console.log('LMNO server listening on localhost:4000');
381 });