From 8d7e2786f5a67a1b9a77c742d7951e0efbe8747d Mon Sep 17 00:00:00 2001 From: Benjamin Auder Date: Tue, 8 Jan 2019 20:24:10 +0100 Subject: [PATCH] Save current state (unmerged, broken, not working...) --- .gitignore | 2 +- README.md | 18 ++-- app.js | 23 +++- db/create.sql | 70 +++++++++++- db/populate.sql | 2 +- models/Challenge.js | 79 ++++++++++++++ models/Game.js | 1 + models/Problem.js | 72 +++++++++++++ models/User.js | 74 +++++++++++++ models/Variant.js | 27 +++++ public/javascripts/components/board.js | 6 +- public/javascripts/components/game.js | 10 +- public/javascripts/components/problems.js | 4 +- public/javascripts/components/room.js | 6 +- public/javascripts/components/rules.js | 2 +- public/javascripts/playCompMove.js | 2 +- public/javascripts/settings.js | 40 +++---- public/javascripts/variant.js | 6 +- routes/challenge.js | 77 +++++++++++++ routes/index.js | 22 ++-- routes/messages.js | 8 +- routes/playing.js | 126 ++++++++++++++++++++++ routes/problems.js | 89 ++++++++------- routes/users.js | 109 +++++++++++++++++++ routes/variant.js | 33 +++--- utils/access.js | 41 +++++++ utils/database.js | 5 + utils/mailer.js.dist | 50 +++++++++ utils/sendEmail.js.dist | 32 ------ views/layout.pug | 5 + views/login_register.pug | 31 ++++++ views/logout_update.pug | 36 +++++++ views/variant.pug | 4 +- 33 files changed, 946 insertions(+), 166 deletions(-) create mode 100644 models/Challenge.js create mode 100644 models/Game.js create mode 100644 models/Problem.js create mode 100644 models/User.js create mode 100644 models/Variant.js create mode 100644 routes/challenge.js create mode 100644 routes/playing.js create mode 100644 routes/users.js create mode 100644 utils/access.js create mode 100644 utils/database.js create mode 100644 utils/mailer.js.dist delete mode 100644 utils/sendEmail.js.dist create mode 100644 views/login_register.pug create mode 100644 views/logout_update.pug diff --git a/.gitignore b/.gitignore index c273cf0c..8a553e65 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ pids # Various files /db/vchess.sqlite -/utils/sendEmail.js +/utils/mailer.js /public/javascripts/socket_url.js # CSS generated files diff --git a/README.md b/README.md index 0b2d396b..8c2ec848 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,16 @@ Website to play to many chess variants, including rare ones - some almost never seen elsewhere, like "l'Échiqueté" [french], renamed "checkered chess" in english. -## Usage - -I hope it's intuitive enough :) - -But, a few important points: +Notes: - Games start with a random assymetric position! - - Your identity is revealed only after a game + - No ratings, no tournaments: no "competition spirit" ## Resources Server side: - node, - - npm packages (see package.json), + - Express, + - Other npm packages (see package.json), Client side: - Vue.js, @@ -32,7 +29,8 @@ Sounds and pieces images where found at various locations. 1. git fat init && git fat pull 2. Execute db/\*.sql scripts to create and fill db/vchess.sqlite 3. Rename and edit public/javascripts/socket\_url.js.dist into socket\_url.js - 4. npm i && npm start + 4. Rename and edit utils/mailer.js.dist into mailer.js + 5. npm i && npm start ## Get involved @@ -42,6 +40,6 @@ All contributions are welcome! For example, - Vue front-end, - Express back-end. -If you wanna help, you can send me an email (address indicated in the "Help" -menu on the website) so that we can discuss what to do and how :) +If you wanna help, you can contact me with the form on the website, +so that we can discuss what to do and how :) If you feel comfortable with the code a pull request is a good start too. diff --git a/app.js b/app.js index 914e29b1..3199af3e 100644 --- a/app.js +++ b/app.js @@ -26,7 +26,7 @@ else } // Allow layout.pug to select the right vue file: -app.locals.development = app.get('env') === 'development'; +app.locals.development = (app.get('env') === 'development'); // view engine setup app.set('views', path.join(__dirname, 'views')); @@ -43,6 +43,27 @@ app.use(sassMiddleware({ })); app.use(express.static(path.join(__dirname, 'public'))); +// Before showing any page, check + save credentials +app.use(function(req, res, next) { + req.loggedIn = false; + if (!req.cookies.token) + return next(); + UserModel.getOne("sessionToken", req.cookies.token, function(err, user) { + if (!!user) + { + req.loggedIn = true; + res.locals.user = { + _id: user._id, + name: user.name, + email: user.email, + notify: user.notify, + }; + } + next(); + }); +}); + +// Routing const routes = require(path.join(__dirname, "routes", "all")); app.use('/', routes); diff --git a/db/create.sql b/db/create.sql index c28bd561..e94b84a3 100644 --- a/db/create.sql +++ b/db/create.sql @@ -1,18 +1,78 @@ -- Database should be in this folder, and named 'vchess.sqlite' create table Variants ( - name varchar primary key, + id integer primary key, + name varchar unique, description text ); +create table Users ( + id integer primary key, + name varchar unique, + email varchar unique, + loginToken varchar, + loginTime datetime, + sessionToken varchar, + notify boolean +); + create table Problems ( - num integer primary key, + id integer primary key, added datetime, - variant varchar, + uid integer, + vid integer, fen varchar, instructions text, solution text, - foreign key (variant) references Variants(name) + foreign key (uid) references Users(id), + foreign key (vid) references Variants(id) +); + +-- All the following tables are for correspondance play only +-- (Live games are stored only in browsers) + +create table Challenges ( + id integer primary key, + added datetime, + uid integer, + vid integer, + foreign key (uid) references Users(id), + foreign key (vid) references Variants(id) +); + +-- Store informations about players who accept a challenge +create table WillPlay ( + cid integer, + uid integer, + yes boolean, + foreign key (cid) references Challenges(id), + foreign key (uid) references Users(id) +); + +create table Games ( + id integer primary key, + vid integer, + fen varchar, --initial position + score varchar, + foreign key (vid) references Variants(id) +); + +-- Store informations about players in a corr game +create table Players ( + uid integer, + color character, + gid integer, + foreign key (uid) references Users(id), + foreign key (gid) references Games(id) +); + +create table Moves ( + gid integer, + move varchar, + played datetime, --when was this move played? + idx integer, --index of the move in the game + color character, --required for e.g. Marseillais Chess + foreign key (gid) references Games(id) ); -PRAGMA foreign_keys = ON; +pragma foreign_keys = on; diff --git a/db/populate.sql b/db/populate.sql index 8cc67d9f..4659704f 100644 --- a/db/populate.sql +++ b/db/populate.sql @@ -1,6 +1,6 @@ -- Re-run this script after variants are added -insert or ignore into Variants values +insert or ignore into Variants (name,description) values ('Alice', 'Both sides of the mirror'), ('Antiking', 'Keep antiking in check'), ('Atomic', 'Explosive captures'), diff --git a/models/Challenge.js b/models/Challenge.js new file mode 100644 index 00000000..9980921c --- /dev/null +++ b/models/Challenge.js @@ -0,0 +1,79 @@ +var db = require("../utils/database"); + +/* + * Structure: + * _id: BSON id + * vid: variant ID + * from: player ID + * to: player ID, undefined if automatch + */ + +exports.create = function(vid, from, to, callback) +{ + let chall = { + "vid": vid, + "from": from + }; + if (!!to) + chall.to = to; + db.challenges.insert(chall, callback); +} + +////////// +// GETTERS + +exports.getById = function(cid, callback) +{ + db.challenges.findOne({_id: cid}, callback); +} + +// For index page: obtain challenges that the player can accept +exports.getByPlayer = function(uid, callback) +{ + db.challenges.aggregate( + {$match: {$or: [ + {"to": uid}, + {$and: [{"from": {$ne: uid}}, {"to": {$exists: false}}]} + ]}}, + {$project: {_id:0, vid:1}}, + {$group: {_id:"$vid", count:{$sum:1}}}, + callback); +} + +// For variant page (challenges related to a player) +exports.getByVariant = function(uid, vid, callback) +{ + db.challenges.find({$and: [ + {"vid": vid}, + {$or: [ + {"to": uid}, + {"from": uid}, + {"to": {$exists: false}}, + ]} + ]}, callback); +} + +////////// +// REMOVAL + +exports.remove = function(cid, callback) +{ + db.challenges.remove({_id: cid}, callback); +} + +// Remove challenges older than 1 month, and 1to1 older than 36h +exports.removeOld = function() +{ + var tsNow = new Date().getTime(); + // 86400000 = 24 hours in milliseconds + var day = 86400000; + db.challenges.find({}, (err,challengeArray) => { + challengeArray.forEach( c => { + if (c._id.getTimestamp() + 30*day < tsNow //automatch + || (!!c.to && c._id.getTimestamp() + 1.5*day < tsNow)) //1 to 1 + { + db.challenges.remove({"_id": c._id}); + } + }); + }); +} diff --git a/models/Game.js b/models/Game.js new file mode 100644 index 00000000..2e57309f --- /dev/null +++ b/models/Game.js @@ -0,0 +1 @@ +//TODO: at least this model (maybe MoveModel ?!) diff --git a/models/Problem.js b/models/Problem.js new file mode 100644 index 00000000..0c800901 --- /dev/null +++ b/models/Problem.js @@ -0,0 +1,72 @@ +var db = require("../utils/database"); + +/* + * Structure: + * _id: problem number (int) + * uid: user id (int) + * vid: variant id (int) + * added: timestamp + * instructions: text + * solution: text + */ + +exports.create = function(vname, fen, instructions, solution) +{ + db.serialize(function() { + db.get("SELECT id FROM Variants WHERE name = '" + vname + "'", (err,variant) => { + db.run( + "INSERT INTO Problems (added, vid, fen, instructions, solution) VALUES " + + "(" + + Date.now() + "," + + variant._id + "," + + fen + "," + + instructions + "," + + solution + + ")"); + }); + }); +} + +exports.getById = function(id, callback) +{ + db.serialize(function() { + db.get( + "SELECT * FROM Problems " + + "WHERE id ='" + id + "'", + callback); + }); +} + +exports.fetchN = function(vname, directionStr, lastDt, MaxNbProblems, callback) +{ + db.serialize(function() { + db.all( + "SELECT * FROM Problems " + + "WHERE vid = (SELECT id FROM Variants WHERE name = '" + vname + "') " + + " AND added " + directionStr + " " + lastDt + " " + + "ORDER BY added " + (directionStr=="<" ? "DESC " : "") + + "LIMIT " + MaxNbProblems, + callback); + }); +} + +exports.update = function(id, uid, fen, instructions, solution) +{ + db.serialize(function() { + db.run( + "UPDATE Problems " + + "fen = " + fen + ", " + + "instructions = " + instructions + ", " + + "solution = " + solution + " " + + "WHERE id = " + id + " AND uid = " + uid); + }); +} + +exports.remove = function(id, uid) +{ + db.serialize(function() { + db.run( + "DELETE FROM Problems " + + "WHERE id = " + id + " AND uid = " + uid); + }); +} diff --git a/models/User.js b/models/User.js new file mode 100644 index 00000000..66b1bf54 --- /dev/null +++ b/models/User.js @@ -0,0 +1,74 @@ +var db = require("../utils/database"); +var maild = require("../utils/mailer.js"); + +/* + * Structure: + * _id: integer + * name: varchar + * email: varchar + * loginToken: token on server only + * loginTime: datetime (validity) + * sessionToken: token in cookies for authentication + * notify: boolean (send email notifications for corr games) + */ + +// User creation +exports.create = function(name, email, notify, callback) +{ + if (!notify) + notify = false; //default + db.serialize(function() { + db.run( + "INSERT INTO Users " + + "(name, email, notify) VALUES " + + "(" + name + "," + email + "," + notify + ")"); + }); +} + +// Find one user (by id, name, email, or token) +exports.getOne = function(by, value, cb) +{ + const delimiter = (typeof value === "string" ? "'" : ""); + db.serialize(function() { + db.get( + "SELECT * FROM Users " + + "WHERE " + by " = " + delimiter + value + delimiter, + callback); + }); +} + +///////// +// MODIFY + +exports.setLoginToken = function(token, uid, cb) +{ + db.serialize(function() { + db.run( + "UPDATE Users " + + "SET loginToken = " + token + " AND loginTime = " + Date.now() + " " + + "WHERE id = " + uid); + }); +} + +exports.setSessionToken = function(token, uid, cb) +{ + // Also empty the login token to invalidate future attempts + db.serialize(function() { + db.run( + "UPDATE Users " + + "SET loginToken = NULL AND sessionToken = " + token + " " + + "WHERE id = " + uid); + }); +} + +exports.updateSettings = function(name, email, notify, cb) +{ + db.serialize(function() { + db.run( + "UPDATE Users " + + "SET name = " + name + + " AND email = " + email + + " AND notify = " + notify + " " + + "WHERE id = " + uid); + }); +} diff --git a/models/Variant.js b/models/Variant.js new file mode 100644 index 00000000..9a19f18c --- /dev/null +++ b/models/Variant.js @@ -0,0 +1,27 @@ +var db = require("../utils/database"); + +/* + * Structure: + * _id: integer + * name: varchar + * description: varchar + */ + +exports.getByName = function(name, callback) +{ + db.serialize(function() { + db.get( + "SELECT * FROM Variants " + + "WHERE name='" + name + "'", + callback); + }); +} + +exports.getAll = function(callback) +{ + db.serialize(function() { + db.all("SELECT * FROM Variants", callback); + }); +} + +//create, update, delete: directly in DB diff --git a/public/javascripts/components/board.js b/public/javascripts/components/board.js index 4cadb0bd..e786e899 100644 --- a/public/javascripts/components/board.js +++ b/public/javascripts/components/board.js @@ -57,7 +57,7 @@ ); // Create board element (+ reserves if needed by variant or mode) const lm = this.vr.lastMove; - const showLight = this.hints && variant!="Dark" && + const showLight = this.hints && variant.name!="Dark" && (this.mode != "idle" || (this.vr.moves.length > 0 && this.cursor==this.vr.moves.length)); const gameDiv = h('div', @@ -80,7 +80,7 @@ _.range(sizeY).map(j => { let cj = (this.mycolor=='w' ? j : sizeY-j-1); let elems = []; - if (this.vr.board[ci][cj] != VariantRules.EMPTY && (variant!="Dark" + if (this.vr.board[ci][cj] != VariantRules.EMPTY && (variant.name!="Dark" || this.score!="*" || this.vr.enlightened[this.mycolor][ci][cj])) { elems.push( @@ -125,7 +125,7 @@ 'light-square': (i+j)%2==0, 'dark-square': (i+j)%2==1, [this.bcolor]: true, - 'in-shadow': variant=="Dark" && this.score=="*" + 'in-shadow': variant.name=="Dark" && this.score=="*" && !this.vr.enlightened[this.mycolor][ci][cj], 'highlight': showLight && !!lm && _.isMatch(lm.end, {x:ci,y:cj}), 'incheck': showLight && incheckSq[ci][cj], diff --git a/public/javascripts/components/game.js b/public/javascripts/components/game.js index 8009edd7..297ccc9d 100644 --- a/public/javascripts/components/game.js +++ b/public/javascripts/components/game.js @@ -82,7 +82,7 @@ Vue.component('my-game', { }, created: function() { const url = socketUrl; - this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant); + this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant._id); // const socketOpenListener = () => { // }; @@ -95,7 +95,7 @@ Vue.component('my-game', { switch (data.code) { case "newmove": //..he played! - this.play(data.move, (variant!="Dark" ? "animate" : null)); + this.play(data.move, (variant.name!="Dark" ? "animate" : null)); break; case "pong": //received if we sent a ping (game still alive on our side) if (this.gameId != data.gameId) @@ -161,7 +161,7 @@ Vue.component('my-game', { }; const socketCloseListener = () => { - this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant); + this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant._id); //this.conn.addEventListener('open', socketOpenListener); this.conn.addEventListener('message', socketMessageListener); this.conn.addEventListener('close', socketCloseListener); @@ -187,7 +187,7 @@ Vue.component('my-game', { // Computer moves web worker logic: (TODO: also for observers in HH games) - this.compWorker.postMessage(["scripts",variant]); + this.compWorker.postMessage(["scripts",variant.name]); const self = this; this.compWorker.onmessage = function(e) { let compMove = e.data; @@ -201,7 +201,7 @@ Vue.component('my-game', { // before they appear on page: const delay = Math.max(500-(Date.now()-self.timeStart), 0); setTimeout(() => { - const animate = (variant!="Dark" ? "animate" : null); + const animate = (variant.name!="Dark" ? "animate" : null); if (self.mode == "computer") //warning: mode could have changed! self.play(compMove[0], animate); if (compMove.length == 2) diff --git a/public/javascripts/components/problems.js b/public/javascripts/components/problems.js index ad8c54c0..00cb944d 100644 --- a/public/javascripts/components/problems.js +++ b/public/javascripts/components/problems.js @@ -175,7 +175,7 @@ Vue.component('my-problems', { last_dt = this.problems[i].added; } } - ajax("/problems/" + variant, "GET", { + ajax("/problems/" + variant.name, "GET", { //TODO: use variant._id ? direction: direction, last_dt: last_dt, }, response => { @@ -201,7 +201,7 @@ Vue.component('my-problems', { }, sendNewProblem: function() { // Send it to the server and close modal - ajax("/problems/" + variant, "POST", { + ajax("/problems/" + variant.name, "POST", { //TODO: with variant._id ? fen: this.newProblem.fen, instructions: this.newProblem.instructions, solution: this.newProblem.solution, diff --git a/public/javascripts/components/room.js b/public/javascripts/components/room.js index 16fcc903..20f01506 100644 --- a/public/javascripts/components/room.js +++ b/public/javascripts/components/room.js @@ -57,7 +57,7 @@ fin de partie corr: garder maxi nbPlayers lastMove sur serveur, pendant 7 jours if (mode=="human" && !oppId) { const storageVariant = localStorage.getItem("variant"); - if (!!storageVariant && storageVariant !== variant + if (!!storageVariant && storageVariant !== variant.name && localStorage["score"] == "*") { return alert(translations["Finish your "] + @@ -82,7 +82,7 @@ fin de partie corr: garder maxi nbPlayers lastMove sur serveur, pendant 7 jours if (!!storageVariant) { const score = localStorage.getItem(prefix+"score"); - if (storageVariant !== variant && score == "*") + if (storageVariant !== variant.name && score == "*") { if (!confirm(storageVariant + translations[": unfinished computer game will be erased"])) @@ -98,7 +98,7 @@ fin de partie corr: garder maxi nbPlayers lastMove sur serveur, pendant 7 jours if (!!storageVariant) { const score = localStorage.getItem(prefix+"score"); - if (storageVariant !== variant && score == "*") + if (storageVariant !== variant.name && score == "*") { if (!confirm(storageVariant + translations[": current analysis will be erased"])) diff --git a/public/javascripts/components/rules.js b/public/javascripts/components/rules.js index 1a597878..02d3a0ca 100644 --- a/public/javascripts/components/rules.js +++ b/public/javascripts/components/rules.js @@ -10,7 +10,7 @@ Vue.component('my-rules', { `, mounted: function() { // AJAX request to get rules content (plain text, HTML) - ajax("/rules/" + variant, "GET", response => { + ajax("/rules/" + variant.name, "GET", response => { let replaceByDiag = (match, p1, p2) => { const args = this.parseFen(p2); return getDiagram(args); diff --git a/public/javascripts/playCompMove.js b/public/javascripts/playCompMove.js index fc69ce24..86769337 100644 --- a/public/javascripts/playCompMove.js +++ b/public/javascripts/playCompMove.js @@ -13,7 +13,7 @@ onmessage = function(e) break; case "init": const fen = e.data[1]; - self.vr = new VariantRules(fen, []); + self.vr = new VariantRules(fen); break; case "newmove": self.vr.play(e.data[1]); diff --git a/public/javascripts/settings.js b/public/javascripts/settings.js index 85f89c1f..f9d7649c 100644 --- a/public/javascripts/settings.js +++ b/public/javascripts/settings.js @@ -1,23 +1,23 @@ // TODO: //à chaque onChange, envoyer matching event settings update //(par exemple si mise à jour du nom, juste envoyer cet update aux autres connectés ...etc) - setMyname: function(e) { - this.myname = e.target.value; - localStorage["username"] = this.myname; - }, - showSettings: function(e) { - this.getRidOfTooltip(e.currentTarget); - document.getElementById("modal-settings").checked = true; - }, - toggleHints: function() { - this.hints = !this.hints; - localStorage["hints"] = (this.hints ? "1" : "0"); - }, - setBoardColor: function(e) { - this.bcolor = e.target.options[e.target.selectedIndex].value; - localStorage["bcolor"] = this.bcolor; - }, - setSound: function(e) { - this.sound = parseInt(e.target.options[e.target.selectedIndex].value); - localStorage["sound"] = this.sound; - }, +// setMyname: function(e) { +// this.myname = e.target.value; +// localStorage["username"] = this.myname; +// }, +// showSettings: function(e) { +// this.getRidOfTooltip(e.currentTarget); +// document.getElementById("modal-settings").checked = true; +// }, +// toggleHints: function() { +// this.hints = !this.hints; +// localStorage["hints"] = (this.hints ? "1" : "0"); +// }, +// setBoardColor: function(e) { +// this.bcolor = e.target.options[e.target.selectedIndex].value; +// localStorage["bcolor"] = this.bcolor; +// }, +// setSound: function(e) { +// this.sound = parseInt(e.target.options[e.target.selectedIndex].value); +// localStorage["sound"] = this.sound; +// }, diff --git a/public/javascripts/variant.js b/public/javascripts/variant.js index 510ea290..eac1ec09 100644 --- a/public/javascripts/variant.js +++ b/public/javascripts/variant.js @@ -22,9 +22,9 @@ new Vue({ }, }); -const continuation = (localStorage.getItem("variant") === variant); - if (continuation) //game VS human has priority - this.continueGame("human"); +//const continuation = (localStorage.getItem("variant") === variant.name); +// if (continuation) //game VS human has priority +// this.continueGame("human"); // TODO: // si quand on arrive il y a une continuation "humaine" : display="game" et retour à la partie ! diff --git a/routes/challenge.js b/routes/challenge.js new file mode 100644 index 00000000..47aaad63 --- /dev/null +++ b/routes/challenge.js @@ -0,0 +1,77 @@ +var router = require("express").Router(); +var ObjectID = require("bson-objectid"); +var ChallengeModel = require('../models/Challenge'); +var UserModel = require('../models/User'); +var ObjectID = require("bson-objectid"); +var access = require("../utils/access"); + +// Only AJAX requests here (from variant page and index) + +// variant page +router.get("/challengesbyvariant", access.logged, access.ajax, (req,res) => { + if (req.query["uid"] != req.user._id) + return res.json({errmsg: "Not your challenges"}); + let uid = ObjectID(req.query["uid"]); + let vid = ObjectID(req.query["vid"]); + ChallengeModel.getByVariant(uid, vid, (err, challengeArray) => { + res.json(err || {challenges: challengeArray}); + }); +}); + +// index +router.get("/challengesbyplayer", access.logged, access.ajax, (req,res) => { + if (req.query["uid"] != req.user._id) + return res.json({errmsg: "Not your challenges"}); + let uid = ObjectID(req.query["uid"]); + ChallengeModel.getByPlayer(uid, (err, challengeArray) => { + res.json(err || {challenges: challengeArray}); + }); +}); + +function createChallenge(vid, from, to, res) +{ + ChallengeModel.create(vid, from, to, (err, chall) => { + res.json(err || { + // A challenge can be sent using only name, thus 'to' is returned + to: chall.to, + cid: chall._id + }); + }); +} + +// from[, to][,nameTo] +router.post("/challenges", access.logged, access.ajax, (req,res) => { + if (req.body.from != req.user._id) + return res.json({errmsg: "Identity usurpation"}); + let from = ObjectID(req.body.from); + let to = !!req.body.to ? ObjectID(req.body.to) : undefined; + let nameTo = !!req.body.nameTo ? req.body.nameTo : undefined; + let vid = ObjectID(req.body.vid); + if (!to && !!nameTo) + { + UserModel.getByName(nameTo, (err,user) => { + access.checkRequest(res, err, user, "Opponent not found", () => { + createChallenge(vid, from, user._id, res); + }); + }); + } + else if (!!to) + createChallenge(vid, from, to, res); + else + createChallenge(vid, from, undefined, res); //automatch +}); + +router.delete("/challenges", access.logged, access.ajax, (req,res) => { + let cid = ObjectID(req.query.cid); + ChallengeModel.getById(cid, (err,chall) => { + access.checkRequest(res, err, chall, "Challenge not found", () => { + if (!chall.from.equals(req.user._id) && !!chall.to && !chall.to.equals(req.user._id)) + return res.json({errmsg: "Not your challenge"}); + ChallengeModel.remove(cid, err => { + res.json(err || {}); + }); + }); + }); +}); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index ab44cf5e..0ed5b07a 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,19 +1,15 @@ let router = require("express").Router(); -const sqlite3 = require('sqlite3');//.verbose(); -const DbPath = __dirname.replace("/routes", "/db/vchess.sqlite"); -const db = new sqlite3.Database(DbPath); -const selectLanguage = require(__dirname.replace("/routes", "/utils/language.js")); +const VariantModel = require("../models/Variant"); +const selectLanguage = require("../utils/language.js"); router.get('/', function(req, res, next) { - db.serialize(function() { - db.all("SELECT * FROM Variants", (err,variants) => { - if (!!err) - return next(err); - res.render('index', { - title: 'club', - variantArray: variants, - lang: selectLanguage(req, res), - }); + VariantModel.getAll((err,variants) => { + if (!!err) + return next(err); + res.render('index', { + title: 'club', + variantArray: variants, + lang: selectLanguage(req, res), }); }); }); diff --git a/routes/messages.js b/routes/messages.js index 7e60b7cd..9a72f5e4 100644 --- a/routes/messages.js +++ b/routes/messages.js @@ -1,15 +1,15 @@ let router = require("express").Router(); -const sendEmail = require(__dirname.replace("/routes", "/utils/sendEmail")); +const mailer = require(__dirname.replace("/routes", "/utils/mailer")); // Send a message through contact form router.post("/messages", (req,res,next) => { if (!req.xhr) return res.json({errmsg: "Unauthorized access"}); - const email = req.body["email"]; + const from = req.body["email"]; const subject = req.body["subject"]; - const content = req.body["content"]; + const body = req.body["body"]; // TODO: sanitize ? - sendEmail(email, subject, content, err => { + mailer.send(from, mailer.contact, subject, body, err => { if (!!err) return res.json({errmsg:err}); // OK, everything fine diff --git a/routes/playing.js b/routes/playing.js new file mode 100644 index 00000000..37592c9d --- /dev/null +++ b/routes/playing.js @@ -0,0 +1,126 @@ +var router = require("express").Router(); +var UserModel = require("../models/User"); +var GameModel = require('../models/Game'); +var VariantModel = require('../models/Variant'); +var ObjectId = require("bson-objectid"); +var access = require("../utils/access"); + +// Notify about a game (start, new move) +function tryNotify(uid, gid, vname, subject) +{ + UserModel.getOne("id", uid, (err,user) => { + if (!!err && user.notify) + { + maild.send({ + from: params.mailFrom, + to: user.email, + subject: subject, + body: params.siteURL + "?v=" + vname + "&g=" + gid + }, err => { + // TODO: log error somewhere. + }); + } + )}; +} + +// From variant page, start game between player 0 and 1 +router.post("/games", access.logged, access.ajax, (req,res) => { + let variant = JSON.parse(req.body.variant); + let players = JSON.parse(req.body.players); + if (!players.includes(req.user._id.toString())) //TODO: should also check challenge... + return res.json({errmsg: "Cannot start someone else's game"}); + let fen = req.body.fen; + // Randomly shuffle colors white/black + if (Math.random() < 0.5) + players = [players[1],players[0]]; + GameModel.create( + ObjectId(variant._id), [ObjectId(players[0]),ObjectId(players[1])], fen, + (err,game) => { + access.checkRequest(res, err, game, "Cannot create game", () => { + if (!!req.body.offlineOpp) + UserModel.tryNotify(ObjectId(req.body.offlineOpp), game._id, variant.name, "New game"); + game.movesLength = game.moves.length; //no need for all moves here + delete game["moves"]; + res.json({game: game}); + }); + } + ); +}); + +// game page +router.get("/games", access.ajax, (req,res) => { + let gameID = req.query["gid"]; + GameModel.getById(ObjectId(gameID), (err,game) => { + access.checkRequest(res, err, game, "Game not found", () => { + res.json({game: game}); + }); + }); +}); + +router.put("/games", access.logged, access.ajax, (req,res) => { + let gid = ObjectId(req.body.gid); + let result = req.body.result; + // NOTE: only game-level life update is "gameover" + GameModel.gameOver(gid, result, ObjectId(req.user._id), (err,game) => { + access.checkRequest(res, err, game, "Cannot find game", () => { + res.json({}); + }); + }); +}); + +// variant page +router.get("/gamesbyvariant", access.logged, access.ajax, (req,res) => { + if (req.query["uid"] != req.user._id) + return res.json({errmsg: "Not your games"}); + let uid = ObjectId(req.query["uid"]); + let vid = ObjectId(req.query["vid"]); + GameModel.getByVariant(uid, vid, (err,gameArray) => { + // NOTE: res.json already stringify, no need to do it manually + res.json(err || {games: gameArray}); + }); +}); + +// For index: only moves count + myColor +router.get("/gamesbyplayer", access.logged, access.ajax, (req,res) => { + if (req.query["uid"] != req.user._id) + return res.json({errmsg: "Not your games"}); + let uid = ObjectId(req.query["uid"]); + GameModel.getByPlayer(uid, (err,games) => { + res.json(err || {games: games}); + }); +}); + +// Load a rules page +router.get("/rules/:variant([a-zA-Z0-9]+)", access.ajax, (req,res) => { + res.render("rules/" + req.params["variant"]); +}); + +// TODO: if newmove fail, takeback in GUI // TODO: check move structure +router.post("/moves", access.logged, access.ajax, (req,res) => { + let gid = ObjectId(req.body.gid); + let fen = req.body.fen; + let vname = req.body.vname; //defined only if !!offlineOpp + // NOTE: storing the moves encoded lead to double stringify --> error at parsing + let move = JSON.parse(req.body.move); + GameModel.addMove(gid, move, fen, req._user._id, (err,game) => { + access.checkRequest(res, err, game, "Cannot find game", () => { + if (!!req.body.offlineOpp) + UserModel.tryNotify(ObjectId(req.body.offlineOpp), gid, vname, "New move"); + res.json({}); + }); + }); +}); + +//TODO: if new chat fails, do not show chat message locally +router.post("/chats", access.logged, access.ajax, (req,res) => { + let gid = ObjectId(req.body.gid); + let uid = ObjectId(req.body.uid); + let msg = req.body.msg; //TODO: sanitize HTML (strip all tags...) + GameModel.addChat(gid, uid, msg, (err,game) => { + access.checkRequest(res, err, game, "Cannot find game", () => { + res.json({}); + }); + }); +}); + +module.exports = router; diff --git a/routes/problems.js b/routes/problems.js index 62a2da15..b94aa601 100644 --- a/routes/problems.js +++ b/routes/problems.js @@ -1,58 +1,67 @@ +// AJAX methods to get, create, update or delete a problem + let router = require("express").Router(); -const sqlite3 = require('sqlite3'); -const DbPath = __dirname.replace("/routes", "/db/vchess.sqlite"); -const db = new sqlite3.Database(DbPath); +const access = require("../utils/access"); +const ProblemModel = require("../models/Problem"); const sanitizeHtml = require('sanitize-html'); const MaxNbProblems = 20; -// Fetch N previous or next problems (AJAX) -router.get("/problems/:variant([a-zA-Z0-9]+)", (req,res) => { - if (!req.xhr) - return res.json({errmsg: "Unauthorized access"}); - const vname = req.params["variant"]; +// Fetch N previous or next problems +router.get("/problems/:vname([a-zA-Z0-9]+)", access.ajax, (req,res) => { + const vname = req.params["vname"]; const directionStr = (req.query.direction == "forward" ? ">" : "<"); const lastDt = req.query.last_dt; if (!lastDt.match(/[0-9]+/)) return res.json({errmsg: "Bad timestamp"}); - db.serialize(function() { - const query = "SELECT * FROM Problems " + - "WHERE variant='" + vname + "' " + - " AND added " + directionStr + " " + lastDt + " " + - "ORDER BY added " + (directionStr=="<" ? "DESC " : "") + - "LIMIT " + MaxNbProblems; - db.all(query, (err,problems) => { - if (!!err) - return res.json(err); - return res.json({problems: problems}); - }); + ProblemModel.fetchN(vname, directionStr, lastDt, MaxNbProblems, (err,problems) => { + if (!!err) + return res.json(err); + return res.json({problems: problems}); }); }); -// Upload a problem (AJAX) -router.post("/problems/:variant([a-zA-Z0-9]+)", (req,res) => { - if (!req.xhr) - return res.json({errmsg: "Unauthorized access"}); - const vname = req.params["variant"]; - const timestamp = Date.now(); - // Sanitize them - const fen = req.body["fen"]; +function sanitizeUserInput(fen, instructions, solution) +{ if (!fen.match(/^[a-zA-Z0-9, /-]*$/)) - return res.json({errmsg: "Bad characters in FEN string"}); - const instructions = sanitizeHtml(req.body["instructions"]).trim(); - const solution = sanitizeHtml(req.body["solution"]).trim(); + return "Bad characters in FEN string"; + instructions = sanitizeHtml(instructions); + solution = sanitizeHtml(solution); if (instructions.length == 0) - return res.json({errmsg: "Empty instructions"}); + return "Empty instructions"; if (solution.length == 0) - return res.json({errmsg: "Empty solution"}); - db.serialize(function() { - let stmt = db.prepare("INSERT INTO Problems " + - "(added,variant,fen,instructions,solution) VALUES (?,?,?,?,?)"); - stmt.run(timestamp, vname, fen, instructions, solution); - stmt.finalize(); - }); - res.json({}); + return "Empty solution"; + return { + fen: fen, + instructions: instructions, + solution: solution + }; +} + +// Upload a problem (sanitize inputs) +router.post("/problems/:vname([a-zA-Z0-9]+)", access.logged, access.ajax, (req,res) => { + const vname = req.params["vname"]; + const s = sanitizeUserInput(req.body["fen"], req.body["instructions"], req.body["solution"]); + if (typeof s === "string") + return res.json({errmsg: s}); + ProblemModel.create(vname, s.fen, s.instructions, s.solution); + res.json({}); }); -// TODO: edit, delete a problem +// Update a problem (also sanitize inputs) +router.put("/problems/:id([0-9]+)", access.logged, access.ajax, (req,res) => { + const pid = req.params["id"]; //problem ID + const s = sanitizeUserInput(req.body["fen"], req.body["instructions"], req.body["solution"]); + if (typeof s === "string") + return res.json({errmsg: s}); + ProblemModel.update(pid, req.user._id, fen, instructions, solution); + res.json({}); +}); + +// Delete a problem +router.delete("/problems/:id([0-9]+)", access.logged, access.ajax, (req,res) => { + const pid = req.params["id"]; //problem ID + ProblemModel.delete(pid, req.user._id); + res.json({}); +}); module.exports = router; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 00000000..dd9914ec --- /dev/null +++ b/routes/users.js @@ -0,0 +1,109 @@ +var router = require("express").Router(); +var UserModel = require('../models/User'); +var maild = require('../utils/mailer'); +var TokenGen = require("../utils/tokenGenerator"); +var access = require("../utils/access"); + +// to: object user +function setAndSendLoginToken(subject, to, res) +{ + // Set login token and send welcome(back) email with auth link + let token = TokenGen.generate(params.token.length); + UserModel.setLoginToken(token, to._id, to.ip, (err,ret) => { + access.checkRequest(res, err, ret, "Cannot set login token", () => { + maild.send({ + from: params.mail.from, + to: to.email, + subject: subject, + body: "Hello " + to.initials + "!\n" + + "Access your account here: " + + params.siteURL + "/authenticate?token=" + token + "\\n" + + "Token will expire in " + params.token.expire/(1000*60) + " minutes." + }, err => { + res.json(err || {}); + }); + }); + }); +} + +// AJAX user life cycle... + +router.post('/register', access.unlogged, access.ajax, (req,res) => { + let name = decodeURIComponent(req.body.name); + let email = decodeURIComponent(req.body.email); + let error = checkObject({name:name, email:email}, "User"); + if (error.length > 0) + return res.json({errmsg: error}); + UserModel.create(name, email, (err,user) => { + access.checkRequest(res, err, user, "Registration failed", () => { + user.ip = req.ip; + setAndSendLoginToken("Welcome to " + params.siteURL, user, res); + }); + }); +}); + +router.put('/sendtoken', access.unlogged, access.ajax, (req,res) => { + let email = decodeURIComponent(req.body.email); + let error = checkObject({email:email}, "User"); + console.log(email) + if (error.length > 0) + return res.json({errmsg: error}); + UserModel.getByEmail(email, (err,user) => { + access.checkRequest(res, err, user, "Unknown user", () => { + setAndSendLoginToken("Token for " + params.siteURL, user, res); + }); + }); +}); + +router.get('/authenticate', access.unlogged, (req,res) => { + UserModel.getByLoginToken(req.query.token, (err,user) => { + access.checkRequest(res, err, user, "Invalid token", () => { + if (user.loginToken.ip != req.ip) + return res.json({errmsg: "IP address mismatch"}); + let now = new Date(); + let tsNow = now.getTime(); + // If token older than params.tokenExpire, do nothing + if (user.loginToken.timestamp + params.token.expire < tsNow) + return res.json({errmsg: "Token expired"}); + // Generate and update session token + destroy login token + let token = TokenGen.generate(params.token.length); + UserModel.setSessionToken(token, user._id, (err,ret) => { + if (!!err) + return res.json(err); + // Set cookie + res.cookie("token", token, { + httpOnly: true, + maxAge: params.cookieExpire + }); + res.redirect("/"); + }); + }); + }); +}); + +router.put('/settings', access.logged, access.ajax, (req,res) => { + let user = JSON.parse(req.body.user); + let error = checkObject(user, "User"); + if (error.length > 0) + return res.json({errmsg: error}); + user._id = ObjectID(req.user._id); + UserModel.updateSettings(user, (err,ret) => { + access.checkRequest(res, err, ret, "Settings update failed", () => { + res.json({}); + }); + }); +}); + +router.get('/logout', access.logged, (req,res) => { + // TODO: cookie + redirect is enough (https, secure cookie + // https://www.information-security.fr/securite-sites-web-lutilite-flags-secure-httponly/ ) + UserModel.logout(req.cookies.token, (err,ret) => { + access.checkRequest(res, err, ret, "Logout failed", () => { + res.clearCookie("token"); + req.user = null; + res.redirect('/'); + }); + }); +}); + +module.exports = router; diff --git a/routes/variant.js b/routes/variant.js index 44b7d804..f45c9594 100644 --- a/routes/variant.js +++ b/routes/variant.js @@ -1,33 +1,28 @@ let router = require("express").Router(); const createError = require('http-errors'); -const sqlite3 = require('sqlite3'); -const DbPath = __dirname.replace("/routes", "/db/vchess.sqlite"); -const db = new sqlite3.Database(DbPath); -const selectLanguage = require(__dirname.replace("/routes", "/utils/language.js")); +const VariantModel = require("../models/Variant"); +const selectLanguage = require("../utils/language.js"); +const access = require("../utils/access"); router.get("/:variant([a-zA-Z0-9]+)", (req,res,next) => { const vname = req.params["variant"]; - db.serialize(function() { - db.all("SELECT * FROM Variants WHERE name='" + vname + "'", (err,variant) => { - if (!!err) - return next(err); - if (!variant || variant.length==0) - return next(createError(404)); - res.render('variant', { - title: vname + ' Variant', - variant: vname, - lang: selectLanguage(req, res), - }); + VariantModel.getByName(vname, (err,variant) => { + if (!!err) + return next(err); + if (!variant) + return next(createError(404)); + res.render('variant', { + title: vname + ' Variant', + variant: variant, //the variant ID might also be useful + lang: selectLanguage(req, res), }); }); }); // Load a rules page (AJAX) -router.get("/rules/:variant([a-zA-Z0-9]+)", (req,res) => { - if (!req.xhr) - return res.json({errmsg: "Unauthorized access"}); +router.get("/rules/:vname([a-zA-Z0-9]+)", access.ajax, (req,res) => { const lang = selectLanguage(req, res); - res.render("rules/" + req.params["variant"] + "/" + lang); + res.render("rules/" + req.params["vname"] + "/" + lang); }); module.exports = router; diff --git a/utils/access.js b/utils/access.js new file mode 100644 index 00000000..ca50b1c8 --- /dev/null +++ b/utils/access.js @@ -0,0 +1,41 @@ +var Access = {}; + +// Prevent access to "users pages" +Access.logged = function(req, res, next) +{ + if (!req.loggedIn) + return res.redirect("/"); + next(); +}; + +// Prevent access to "anonymous pages" +Access.unlogged = function(req, res, next) +{ + if (!!req.loggedIn) + return res.redirect("/"); + next(); +}; + +// Prevent direct access to AJAX results +Access.ajax = function(req, res, next) +{ + if (!req.xhr) + return res.json({errmsg: "Unauthorized access"}); + next(); +} + +// Check for errors before callback (continue page loading). TODO: better name. +Access.checkRequest = function(res, err, out, msg, cb) +{ + if (!!err) + return res.json(err); + if (!out + || (Array.isArray(out) && out.length == 0) + || (typeof out === "object" && Object.keys(out).length == 0)) + { + return res.json({errmsg: msg}); + } + cb(); +} + +module.exports = Access; diff --git a/utils/database.js b/utils/database.js new file mode 100644 index 00000000..39c7e5e0 --- /dev/null +++ b/utils/database.js @@ -0,0 +1,5 @@ +const sqlite3 = require('sqlite3'); +const DbPath = __dirname.replace("/utils", "/db/vchess.sqlite"); +const db = new sqlite3.Database(DbPath); + +module.exports = db; diff --git a/utils/mailer.js.dist b/utils/mailer.js.dist new file mode 100644 index 00000000..06cdc591 --- /dev/null +++ b/utils/mailer.js.dist @@ -0,0 +1,50 @@ +const nodemailer = require('nodemailer'); + +const contact = "your_contact_email"; + +const send = function(from, to, subject, body, cb) +{ + // Create reusable transporter object using the default SMTP transport + const transporter = nodemailer.createTransport({ + host: "smtp_host_address", + port: 465, //if secure; otherwise use 587 + secure: true, + auth: { + user: "user_name", + pass: "user_password" + } + }); + + // Setup email data with unicode symbols + const mailOptions = { + from: from, //note: some SMTP serves might forbid this + to: to, + subject: subject, + text: body, + }; + + // Avoid the actual sending in development mode + const env = process.env.NODE_ENV || 'development'; + if ('development' === env) + { + console.log("New mail: from " + from + " / to " + to); + console.log("Subject: " + subject); + let msgText = body.split('\\n'); + msgText.forEach(msg => { console.log(msg); }); + return cb(); + } + + // Send mail with the defined transport object + transporter.sendMail(mailOptions, (error, info) => { + if (!!error) + return cb(error); + // Ignore info. Option: + //console.log('Message sent: %s', info.messageId); + return cb(); + }); +}; + +module.exports = { + contact: contact, + send: send +}; diff --git a/utils/sendEmail.js.dist b/utils/sendEmail.js.dist deleted file mode 100644 index cad123e8..00000000 --- a/utils/sendEmail.js.dist +++ /dev/null @@ -1,32 +0,0 @@ -const nodemailer = require('nodemailer'); - -module.exports = function(email, subject, content, cb) -{ - // create reusable transporter object using the default SMTP transport - const transporter = nodemailer.createTransport({ - host: "smtp_host_address", - port: 465, //if secure; otherwise use 587 - secure: true, - auth: { - user: "user_name", - pass: "user_password" - } - }); - - // setup email data with unicode symbols - const mailOptions = { - from: email, //note: some SMTP serves might forbid this - to: "contact_email", - subject: subject, - text: content, - }; - - // send mail with defined transport object - transporter.sendMail(mailOptions, (error, info) => { - if (!!error) - return cb(error); - // Ignore info. Option: - //console.log('Message sent: %s', info.messageId); - return cb(); - }); -}; diff --git a/views/layout.pug b/views/layout.pug index da99fc31..60b7adcf 100644 --- a/views/layout.pug +++ b/views/layout.pug @@ -33,6 +33,10 @@ html include translations/es when "fr" include translations/fr + if !user + include login_register + else + include logout_update include contactForm include settings main @@ -54,4 +58,5 @@ html script(src="https://cdn.jsdelivr.net/npm/vue") script. const translations = !{JSON.stringify(translations)}; + const user = !{JSON.stringify(user)}; block javascripts diff --git a/views/login_register.pug b/views/login_register.pug new file mode 100644 index 00000000..c831c8a1 --- /dev/null +++ b/views/login_register.pug @@ -0,0 +1,31 @@ +extends layout + +block css + link(rel="stylesheet", href="/stylesheets/login.css") + +block content + .mui-container + .row + .mui-col.xs-12.mui-col-sm-8.mui-col-sm-offset-2.mui-col-md-6.mui-col-md-offset-3.mui-col-lg-4.mui-col-lg-offset-4.mui--z1.white.pad-updown.pad-sides + form#loginForm(@submit.prevent="submit") + .mui-textfield.mui-textfield--float-label + input#email(type="email" ref="userEmail" v-model="user.email") + label#labEmail(for="email") Email + .mui-textfield.mui-textfield--float-label(v-show="stage == 'Register'") + input#name(type="text" v-model="user.name") + label#labName(for="name") Name + .mui--pull-left.space-bottom.space-top + button#submit.mui-btn.mui-btn--primary(@click.prevent="submit") + span {{ stage=="Login" ? "Go" : "Send" }} + i.material-icons.right send + .mui--pull-right.space-bottom.space-top + p + button.mui-btn.mui-btn--accent(@click.prevent="toggleStage()") + span {{ stage=="Login" ? "Register" : "Login" }} + #dialog.mui--hide.space-top + +block javascripts + script(src="//cdnjs.cloudflare.com/ajax/libs/vue/2.5.2/vue.min.js") + script(src="/javascripts/utils/dialog.js") + script(src="/javascripts/utils/validation.js") + script(src="/javascripts/login.js") diff --git a/views/logout_update.pug b/views/logout_update.pug new file mode 100644 index 00000000..1b84483b --- /dev/null +++ b/views/logout_update.pug @@ -0,0 +1,36 @@ +extends layout + +block css + link(rel="stylesheet", href="/stylesheets/settings.css") + +block content + .mui-container-fluid + .mui-row + .mui-col-xs-12.mui-col-sm-10.mui-col-sm-offset-1.mui-col-md-8.mui-col-md-offset-2.mui-col-lg-6.mui-col-lg-offset-3.mui--z1.white.pad-updown.pad-sides + form#settingsForm(@submit.prevent="submit") + .mui-textfield.mui-textfield--float-label + input#email(type="email" ref="userEmail" v-model="user.email") + label#labEmail.active(for="email") Email + .mui-textfield.mui-textfield--float-label + input#name(type="text" v-model="user.name") + label#labName.active(for="name") Name + p + span Theme      + button(v-for="theme in themes" class="theme-btn mui-btn grey" + :class="themeClass(theme)" @click.prevent="toggleTheme(theme)") + | {{ theme }} + .mui-radio + input#notify(type="checkbox" v-model="user.notify") + label(for="notify") Notify new moves & games + button#submit.mui-btn.mui-btn--primary(@click.prevent="submit") + span Apply + i.material-icons.right send + #dialog.mui--hide.space-top + +block javascripts + script(src="//cdnjs.cloudflare.com/ajax/libs/vue/2.5.2/vue.min.js") + script(src="/javascripts/utils/dialog.js") + script(src="/javascripts/utils/validation.js") + script. + var user = !{JSON.stringify(user)}; + script(src="/javascripts/settings.js") diff --git a/views/variant.pug b/views/variant.pug index 2fbf21e9..9419998a 100644 --- a/views/variant.pug +++ b/views/variant.pug @@ -38,10 +38,10 @@ block javascripts script(src="/javascripts/utils/datetime.js") script(src="/javascripts/socket_url.js") script(src="/javascripts/base_rules.js") - script(src="/javascripts/variants/" + variant + ".js") + script(src="/javascripts/variants/" + variant.name + ".js") script. const V = VariantRules; //because this variable is often used - const variant = "#{variant}"; + const variant = !{JSON.stringify(variant)}; script(src="/javascripts/components/room.js") script(src="/javascripts/components/gameList.js") script(src="/javascripts/components/rules.js") -- 2.44.0