]> git.cworth.org Git - empires-server/blob - lmno.js
LMNO: Simplify storage of the games array by not storing ID next to game
[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 app.use(cors());
36 app.use(body_parser.urlencoded({ extended: false }));
37 app.use(body_parser.json());
38 app.use(session({
39   secret: lmno_config.session_secret,
40   resave: false,
41   saveUninitialized: false
42 }));
43
44 nunjucks.configure("templates", {
45   autoescape: true,
46   express: app
47 });
48
49 /* Load each of our game mini-apps. */
50 const engines = {
51   empires: require("./empires"),
52   tictactoe: require("./tictactoe")
53 };
54
55 class LMNO {
56   constructor() {
57     this.games = {};
58   }
59
60   generate_id() {
61     return Array(4).fill(null).map(() => LMNO.letters.charAt(Math.floor(Math.random() * LMNO.letters.length))).join('');
62   }
63
64   create_game(engine_name) {
65     do {
66       var id = this.generate_id();
67     } while (id in this.games);
68
69     const engine = engines[engine_name];
70
71     const game = new engine.Game(id);
72
73     this.games[id] = {
74       game: game
75     };
76
77     return id;
78   }
79 }
80
81 /* Some letters we don't use in our IDs:
82  *
83  * 1. Vowels (AEIOU) to avoid accidentally spelling an unfortunate word
84  * 2. Lowercase letters (replace with corresponding capital on input)
85  * 3. N (replace with M on input)
86  * 4. P (replace with B on input)
87  * 5. S (replace with F on input)
88  */
89 LMNO.letters = "BCDFGHJKLMQRTVWXYZ";
90
91 const lmno = new LMNO();
92
93 /* Force a game ID into a canonical form as described above. */
94 function lmno_canonize(id) {
95   /* Capitalize */
96   id = id.toUpperCase();
97
98   /* Replace unused letters with nearest phonetic match. */
99   id = id.replace(/N/g, 'M');
100   id = id.replace(/P/g, 'B');
101   id = id.replace(/S/g, 'F');
102
103   /* Replace unused numbers nearest visual match. */
104   id = id.replace(/0/g, 'O');
105   id = id.replace(/1/g, 'I');
106   id = id.replace(/5/g, 'S');
107
108   return id;
109 }
110
111 app.post('/new/:game_engine', (request, response) =>  {
112   const game_engine = request.params.game_engine;
113   const game_id = lmno.create_game(game_engine);
114   response.send(JSON.stringify(game_id));
115 });
116
117 /* Redirect any requests to a game ID at the top-level.
118  *
119  * Specifically, after obtaining the game ID (from the path) we simply
120  * lookup the game engine for the corresponding game and then redirect
121  * to the engine- and game-specific path.
122  */
123 app.get('/[a-zA-Z0-9]{4}', (request, response) => {
124   const game_id = request.path.replace(/\//g, "");
125   const canon_id = lmno_canonize(game_id);
126
127   /* Redirect user to page with the canonical ID in it. */
128   if (game_id !== canon_id) {
129     response.redirect(301, `/${canon_id}/`);
130     return;
131   }
132
133   const game = lmno.games[game_id];
134   if (game === undefined) {
135       response.sendStatus(404);
136       return;
137   }
138   response.redirect(301, `/${game.game.meta.identifier}/${game.game.id}/`);
139 });
140
141 /* LMNO middleware to lookup the game. */
142 app.use('/:engine([^/]+)/:game_id([a-zA-Z0-9]{4})', (request, response, next) => {
143   const engine = request.params.engine;
144   const game_id = request.params.game_id;
145   const canon_id = lmno_canonize(game_id);
146
147   /* Redirect user to page with the canonical ID in it, also ensuring
148    * that the game ID is _always_ followed by a slash. */
149   const has_slash = new RegExp(`^/${engine}/${game_id}/`);
150   if (game_id !== canon_id ||
151       ! has_slash.test(request.originalUrl))
152   {
153     const old_path = new RegExp(`/${engine}/${game_id}/?`);
154     const new_path = `/${engine}/${canon_id}/`;
155     const new_url = request.originalUrl.replace(old_path, new_path);
156     response.redirect(301, new_url);
157     return;
158   }
159
160   /* See if there is any game with this ID. */
161   const game = lmno.games[game_id];
162   if (game === undefined) {
163     response.sendStatus(404);
164     return;
165   }
166
167   /* Stash the game onto the request to be used by the game-specific code. */
168   request.game = game.game;
169   next();
170 });
171
172 function auth_admin(request, response, next) {
173   /* If there is no user associated with this session, redirect to the login
174    * page (and set a "next" query parameter so we can come back here).
175    */
176   if (! request.session.user) {
177     response.redirect(302, "/login?next=" + request.path);
178     return;
179   }
180
181   /* If the user is logged in but not authorized to view the page then 
182    * we return that error. */
183   if (request.session.user.role !== "admin") {
184     response.status(401).send("Unauthorized");
185     return;
186   }
187   next();
188 }
189
190 app.get('/logout', (request, response) => {
191   request.session.user = undefined;
192   request.session.destroy();
193
194   response.send("You are now logged out.");
195 });
196
197 app.get('/login', (request, response) => {
198   if (request.session.user) {
199     response.send("Welcome, " + request.session.user + ".");
200     return;
201   }
202
203   response.render('login.html');
204 });
205
206 app.post('/login', async (request, response) => {
207   const username = request.body.username;
208   const password = request.body.password;
209   const user = lmno_config.users[username];
210   if (! user) {
211     response.sendStatus(404);
212     return;
213   }
214   const match = await bcrypt.compare(password, user.password_hash_bcrypt);
215   if (! match) {
216     response.sendStatus(404);
217     return;
218   }
219   request.session.user = { username: user.username, role: user.role };
220   response.sendStatus(200);
221   return;
222 });
223
224 /* API to set uer profile information */
225 app.put('/profile', (request, response) => {
226   const nickname = request.body.nickname;
227   if (nickname) {
228     request.session.nickname = nickname;
229     request.session.save();
230   }
231   response.send();
232 });
233
234 /* An admin page (only available to admin users, of course) */
235 app.get('/admin/', auth_admin, (request, response) => {
236   let active = [];
237   let idle = [];
238
239   for (let id in lmno.games) {
240     if (lmno.games[id].game.clients.length)
241       active.push(lmno.games[id]);
242     else
243       idle.push(lmno.games[id]);
244   }
245   response.render('admin.html', { test: "foobar", games: { active: active, idle: idle}});
246 });
247
248
249 /* Mount sub apps. only _after_ we have done all the middleware we need. */
250 for (let key in engines) {
251   const engine = engines[key];
252
253   /* Add routes that are common to all games. */
254   engine.router.get('/', (request, response) => {
255     const game = request.game;
256
257     if (! request.session.nickname)
258       response.render('choose-nickname.html', { game_name: game.meta.name });
259     else
260       response.render(`${game.meta.identifier}-game.html`);
261   });
262
263   /* And mount the whole router at the path for the game. */
264   app.use(`/${engine.Game.meta.identifier}/[a-zA-Z0-9]{4}/`, engine.router);
265 }
266
267 app.listen(4000, function () {
268   console.log('LMNO server listening on localhost:4000');
269 });