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