From 652852e3104ce4020de53231ee7691a4970439d6 Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Sat, 1 May 2021 00:48:35 +0200 Subject: Add server and lobby code. --- connect-better-sqlite3.js | 91 ++++ package.json | 19 + public/images/rally-the-troops.png | Bin 0 -> 3855 bytes public/images/rally-the-troops.svg | 1 + public/style.css | 58 +++ server.js | 943 +++++++++++++++++++++++++++++++++++++ tools/editgame.sh | 8 + tools/makecert.sh | 6 + tools/purge.sh | 6 + tools/readgame.sh | 10 + tools/sql/schema.txt | 86 ++++ tools/start.sh | 2 + tools/stop.sh | 2 + tools/writegame.sh | 7 + views/about.ejs | 83 ++++ views/banned.ejs | 3 + views/change_password.ejs | 15 + views/create.ejs | 22 + views/error.ejs | 1 + views/header.ejs | 32 ++ views/index.ejs | 19 + views/info.ejs | 83 ++++ views/join.ejs | 85 ++++ views/login.ejs | 15 + views/profile.ejs | 84 ++++ views/signup.ejs | 18 + views/users.ejs | 16 + 27 files changed, 1715 insertions(+) create mode 100644 connect-better-sqlite3.js create mode 100644 package.json create mode 100644 public/images/rally-the-troops.png create mode 100644 public/images/rally-the-troops.svg create mode 100644 public/style.css create mode 100644 server.js create mode 100644 tools/editgame.sh create mode 100644 tools/makecert.sh create mode 100644 tools/purge.sh create mode 100644 tools/readgame.sh create mode 100644 tools/sql/schema.txt create mode 100644 tools/start.sh create mode 100644 tools/stop.sh create mode 100644 tools/writegame.sh create mode 100644 views/about.ejs create mode 100644 views/banned.ejs create mode 100644 views/change_password.ejs create mode 100644 views/create.ejs create mode 100644 views/error.ejs create mode 100644 views/header.ejs create mode 100644 views/index.ejs create mode 100644 views/info.ejs create mode 100644 views/join.ejs create mode 100644 views/login.ejs create mode 100644 views/profile.ejs create mode 100644 views/signup.ejs create mode 100644 views/users.ejs diff --git a/connect-better-sqlite3.js b/connect-better-sqlite3.js new file mode 100644 index 0000000..4731897 --- /dev/null +++ b/connect-better-sqlite3.js @@ -0,0 +1,91 @@ +/* + * connect-better-sqlite3 + * + * Copyright (c) 2010-2020 TJ Holowaychuk + * Copyright (c) 2011 tnantoka + * Copyright (c) 2012 David Feinberg + * Copyright (c) 2021 Tor Andersson + * + * MIT Licensed + */ + +module.exports = function (session) { + const SQLite = require('better-sqlite3'); + + function noop() {} + function now() { return Math.ceil(Date.now() / 1000); } + function seconds(date) { return Math.ceil(new Date(date).getTime() / 1000); } + + class SQLiteStore extends session.Store { + + constructor(options = {}) { + super(options); + + let table = options.table || 'sessions'; + let db_path = options.db || table; + if (db_path != ':memory:') + db_path = (options.dir || '.') + '/' + db_path; + + let db = new SQLite(db_path, options.mode); + db.exec("CREATE TABLE IF NOT EXISTS "+table+" (sid PRIMARY KEY, expires INTEGER, sess TEXT)"); + db.exec("DELETE FROM "+table+" WHERE ? > expires"); + db.exec("VACUUM"); + + this.sql_destroy = db.prepare("DELETE FROM "+table+" WHERE sid = ?"); + this.sql_get = db.prepare("SELECT sess FROM "+table+" WHERE sid = ? AND ? <= expires"); + this.sql_set = db.prepare("INSERT OR REPLACE INTO "+table+" VALUES (?,?,?)"); + this.sql_touch = db.prepare("UPDATE "+table+" SET expires = ? WHERE sid = ? AND ? <= expires"); + } + + destroy(sid, cb = noop) { + try { + this.sql_destroy.run(sid); + cb(null); + } catch (err) { + cb(err); + } + } + + get(sid, cb = noop) { + try { + let sess = this.sql_get.get(sid, now()); + if (sess) + return cb(null, JSON.parse(sess.sess)); + return cb(null, null); + } catch (err) { + return cb(err, null); + } + } + + set(sid, sess, cb = noop) { + try { + let expires; + if (sess && sess.cookie && sess.cookie.expires) + expires = seconds(sess.cookie.expires); + else + expires = now() + 86400; + this.sql_set.run(sid, expires, JSON.stringify(sess)); + cb(null); + } catch (err) { + cb(err); + } + } + + touch(sid, sess, cb = noop) { + try { + if (sess && sess.cookie && sess.cookie.expires) { + let expires = seconds(sess.cookie.expires); + this.sql_touch.run(expires, sid, now()); + cb(null); + } else { + cb(null); + } + } catch (err) { + cb(err); + } + } + + } + + return SQLiteStore; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..93c4049 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "rally-the-troops", + "version": "0.0.1", + "description": "Rally the Troops!", + "dependencies": { + "better-sqlite3": "^7.1.2", + "connect": "^3.7.0", + "connect-flash": "^0.1.1", + "cookie-parser": "^1.4.5", + "dotenv": "^8.2.0", + "ejs": "^3.1.5", + "express": "^4.17.1", + "express-session": "^1.17.1", + "passport": "^0.4.1", + "passport-local": "^1.0.0", + "passport.socketio": "^3.7.0", + "socket.io": "^3.0.4" + } +} diff --git a/public/images/rally-the-troops.png b/public/images/rally-the-troops.png new file mode 100644 index 0000000..c792857 Binary files /dev/null and b/public/images/rally-the-troops.png differ diff --git a/public/images/rally-the-troops.svg b/public/images/rally-the-troops.svg new file mode 100644 index 0000000..da7b407 --- /dev/null +++ b/public/images/rally-the-troops.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..dfd7754 --- /dev/null +++ b/public/style.css @@ -0,0 +1,58 @@ +button, select { + font-family: "Source Sans", "Verdana", "Dingbats", "Noto Emoji", sans-serif; + font-size: 16px; +} +html, input { + font-family: "Source Serif", "Georgia", "Dingbats", "Noto Emoji", serif; + font-size: 16px; +} +html, body { margin: 0; } +h1 { margin-left: -2px; } +h2 { margin-left: -1px; } +.header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 2px solid brown; + padding-right: 1em; +} +.header img { display: block; margin: 4px 0 -2px 2px; } +.header span { margin: 0 1em; } +.header a { color: black; } +.main { margin: 2em; } +.main p { max-width: 50rem; } +.main hr { max-width: 50rem; margin-right: auto; margin-left: 0; } +.main hr { border: none; border-top: 2px dotted brown; } +.main hr + p { font-style: italic; } +table.wide { min-width: 50rem; } +.error { color: brown; font-style: italic; white-space: pre-wrap; } +.logo { float: left; margin: 0 20px 5px 0; box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.5); } +table { border-collapse: collapse; } +th { text-align: left; background-color: gainsboro; } +th, td { border: 1px solid black; } +th, td { padding: 3px 1ex; } +label { user-select: none; } +button, input, select { font-size: 1rem; margin: 5px 0; } +input[type="text"], input[type="password"] { padding: 5px; } +select { padding-right: 20px; } +form { display: inline; } +.nowrap { white-space: nowrap; } +.your_turn { background-color: lemonchiffon; } +button, select { + margin: 5px 10px 5px 0; + padding: 1px 10px; + background-color: gainsboro; +} +button:disabled { + color: gray; + border: 2px solid gainsboro; + outline: 1px solid gray; +} +button:enabled, select { + border: 2px outset white; + outline: 1px solid black; +} +button:enabled:active:hover, select:active { + border: 2px inset white; + padding: 2px 9px 0px 11px; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..f3af3d7 --- /dev/null +++ b/server.js @@ -0,0 +1,943 @@ +"use strict"; + +const fs = require('fs'); +const express = require('express'); +const express_session = require('express-session'); +const passport = require('passport'); +const passport_local = require('passport-local'); +const passport_socket = require('passport.socketio'); +const body_parser = require('body-parser'); +const connect_flash = require('connect-flash'); +const crypto = require('crypto'); +const sqlite3 = require('better-sqlite3'); +const SQLiteStore = require('./connect-better-sqlite3')(express_session); + +require('dotenv').config(); + +const SESSION_SECRET = "Caesar has a big head!"; + +const MAX_OPEN_GAMES = 3; + +let sessionStore = new SQLiteStore(); +let db = new sqlite3(process.env.DATABASE || "./db"); +let app = express(); +let http_port = process.env.PORT || 8080; +let https_port = process.env.HTTPS_PORT || 8443; +let http = require('http').createServer(app); +let https = require('https').createServer({ + key: fs.readFileSync(process.env.SSL_KEY || "key.pem"), + cert: fs.readFileSync(process.env.SSL_CERT || "cert.pem") + }, app); +let socket_io = require('socket.io'); +let io1 = socket_io(http); +let io2 = socket_io(https); +let io = { + use: function (fn) { io1.use(fn); io2.use(fn); }, + on: function (ev,fn) { io1.on(ev,fn); io2.on(ev,fn); }, +}; + +app.disable('etag'); +app.set('view engine', 'ejs'); +app.use(body_parser.urlencoded({extended:false})); +app.use(express_session({ + secret: SESSION_SECRET, + resave: false, + saveUninitialized: true, + store: sessionStore, + cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } +})); +app.use(connect_flash()); + +io.use(passport_socket.authorize({ + key: 'connect.sid', + secret: SESSION_SECRET, + store: sessionStore, +})); + +const is_immutable = /\.(svg|png|jpg|jpeg|woff2)$/; + +function setHeaders(res, path) { + if (is_immutable.test(path)) + res.set("Cache-Control", "public, max-age=86400, immutable"); +} + +app.use(express.static('public', { setHeaders: setHeaders })); + +function LOG(req, ...msg) { + let name; + if (req.isAuthenticated()) + name = req.user.mail; + else + name = "guest"; + let time = new Date().toISOString().substring(0,19).replace("T", " "); + console.log(time, req.connection.remoteAddress, name, ...msg); +} + +function SLOG(socket, ...msg) { + let name = socket.request.user.mail; + let time = new Date().toISOString().substring(0,19).replace("T", " "); + console.log(time, socket.request.connection.remoteAddress, name, + socket.id, socket.title_id, socket.game_id, socket.role, ...msg); +} + +function human_date(time) { + var date = time ? new Date(time + " UTC") : new Date(0); + var seconds = (Date.now() - date.getTime()) / 1000; + var days = Math.floor(seconds / 86400); + if (days == 0) { + if (seconds < 60) return "now"; + if (seconds < 120) return "1 minute ago"; + if (seconds < 3600) return Math.floor(seconds / 60) + " minutes ago"; + if (seconds < 7200) return "1 hour ago"; + if (seconds < 86400) return Math.floor(seconds / 3600) + " hours ago"; + } + if (days == 1) return "Yesterday"; + if (days < 14) return days + " days ago"; + if (days < 31) return Math.ceil(days / 7) + " weeks ago"; + return date.toISOString().substring(0,10); +} + +function humanize(rows) { + for (let row of rows) { + row.ctime = human_date(row.ctime); + row.mtime = human_date(row.mtime); + } +} + +function is_email(email) { + return email.match(/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/); +} + +function clean_user_name(name) { + name = name.replace(/^ */,'').replace(/ *$/,'').replace(/ */g,' '); + if (name.length > 50) + name = name.substring(0, 50); + return name; +} + +function hash_password(password, salt) { + let hash = crypto.createHash('sha256'); + hash.update(password); + hash.update(salt); + return hash.digest('hex'); +} + +function get_avatar(mail) { + if (!mail) + mail = "foo@example.com"; + let digest = crypto.createHash('md5').update(mail.trim().toLowerCase()).digest('hex'); + return '//www.gravatar.com/avatar/' + digest + '?d=mp'; +} + +/* + * USER PROFILES + */ + +const sql_blacklist_ip = db.prepare("SELECT COUNT(*) FROM blacklist_ip WHERE ip = ?").raw(); +const sql_blacklist_mail = db.prepare("SELECT COUNT(*) AS count FROM blacklist_mail WHERE ? LIKE mail").raw(); + +function is_blacklisted(ip, mail) { + if (sql_blacklist_ip.get(ip)[0] != 0) + return true; + if (sql_blacklist_mail.get(mail)[0] != 0) + return true; + return false; +} + +const sql_deserialize_user = db.prepare("SELECT user_id, name, mail FROM users WHERE user_id = ?"); +const sql_update_last_seen = db.prepare("UPDATE users SET aip = ?, atime = datetime('now') WHERE user_id = ?"); +const sql_login_select = db.prepare("SELECT user_id, name, mail, password, salt FROM users WHERE name = ? OR mail = ?"); + +passport.serializeUser(function (user, done) { + return done(null, user.user_id); +}); + +passport.deserializeUser(function (user_id, done) { + try { + let row = sql_deserialize_user.get(user_id); + if (!row) + return done(null, false); + return done(null, row); + } catch (err) { + console.log(err); + return done(null, false); + } +}); + +function local_login(req, name_or_mail, password, done) { + try { + if (!is_email(name_or_mail)) + name_or_mail = clean_user_name(name_or_mail); + LOG(req, "POST /login", name_or_mail); + let row = sql_login_select.get(name_or_mail, name_or_mail); + if (!row) + return setTimeout(() => done(null, false, req.flash('message', "User not found.")), 1000); + if (is_blacklisted(req.connection.remoteAddress, row.mail)) + return setTimeout(() => done(null, false, req.flash('message', "Sorry, but this IP or account has been banned.")), 1000); + let hash = hash_password(password, row.salt); + if (hash != row.password) + return setTimeout(() => done(null, false, req.flash('message', "Wrong password.")), 1000); + sql_update_last_seen.run(req.connection.remoteAddress, row.user_id); + done(null, row); + } catch (err) { + done(null, false, req.flash('message', err.toString())); + } +} + +const sql_signup_check = db.prepare("SELECT user_id, name FROM users WHERE name = ? OR mail = ?"); +const sql_signup_insert = db.prepare("INSERT INTO users (name, mail, password, salt, ctime, cip, atime, aip) VALUES (?,?,?,?,datetime('now'),?,datetime('now'),?)"); +const sql_signup_login = db.prepare("SELECT user_id, name FROM users WHERE name = ? AND password = ?"); + +function local_signup(req, name, password, done) { + try { + let mail = req.body.mail; + name = clean_user_name(name); + LOG(req, "POST /signup", name, mail); + if (is_blacklisted(req.connection.remoteAddress, mail)) + return setTimeout(() => done(null, false, req.flash('message', "Sorry, but this IP or account has been banned.")), 1000); + if (password.length < 4) + return done(null, false, req.flash('message', "Password is too short!")); + if (password.length > 100) + return done(null, false, req.flash('message', "Password is too long!")); + // TODO: actual verification if process.env.VERIFY_EMAIL + if (!is_email(mail)) + return done(null, false, req.flash('message', "Invalid mail address!")); + let row = sql_signup_check.get(name, mail); + if (row) + return done(null, false, req.flash('message', "User name or mail is already taken.")); + let salt = crypto.randomBytes(32).toString('hex'); + let hash = hash_password(password, salt); + let ip = req.connection.remoteAddress; + sql_signup_insert.run(name, mail, hash, salt, ip, ip); + row = sql_signup_login.get(name, hash); + done(null, row); + } catch (err) { + done(null, false, req.flash('message', err.toString())); + } +} + +passport.use('local-login', new passport_local.Strategy({ passReqToCallback: true }, local_login)); +passport.use('local-signup', new passport_local.Strategy({ passReqToCallback: true }, local_signup)); + +app.use(passport.initialize()); +app.use(passport.session()); + +function update_last_seen(req) { + sql_update_last_seen.run(req.connection.remoteAddress, req.user.user_id); +} + +function must_be_logged_in(req, res, next) { + if (!req.isAuthenticated()) + return res.redirect('/login'); + if (sql_blacklist_ip.get(req.connection.remoteAddress)[0] != 0) + return res.redirect('/banned'); + if (sql_blacklist_mail.get(req.user.mail)[0] != 0) + return res.redirect('/banned'); + update_last_seen(req); + return next(); +} + +app.get('/favicon.ico', function (req, res) { + res.status(204).send(); +}); + +app.get('/about', function (req, res) { + res.render('about.ejs', { user: req.user }); +}); + +app.get('/logout', function (req, res) { + LOG(req, "GET /logout"); + req.logout(); + res.redirect('/login'); +}); + +app.get('/banned', function (req, res) { + LOG(req, "GET /banned"); + res.render('banned.ejs', { user: req.user, message: req.flash('message') }); +}); + +app.get('/login', function (req, res) { + LOG(req, "GET /login"); + res.render('login.ejs', { user: req.user, message: req.flash('message') }); +}); + +app.get('/signup', function (req, res) { + LOG(req, "GET /signup"); + res.render('signup.ejs', { user: req.user, message: req.flash('message') }); +}); + +app.post('/login', + passport.authenticate('local-login', { + successRedirect: '/', + failureRedirect: '/login', + failureFlash: true + }) +); + +app.post('/signup', + passport.authenticate('local-signup', { + successRedirect: '/', + failureRedirect: '/signup', + failureFlash: true + }) +); + +app.get('/users', function (req, res) { + LOG(req, "GET /users"); + let rows = db.prepare("SELECT name, mail, ctime, atime FROM users ORDER BY atime DESC").all(); + rows.forEach(row => { + row.avatar = get_avatar(row.mail); + row.ctime = human_date(row.ctime); + row.atime = human_date(row.atime); + }); + res.render('users.ejs', { user: req.user, message: req.flash('message'), userList: rows }); +}); + +app.get('/change_password', must_be_logged_in, function (req, res) { + LOG(req, "GET /change_password"); + res.render('change_password.ejs', { user: req.user, message: req.flash('message') }); +}); + +app.post('/change_password', must_be_logged_in, function (req, res) { + try { + let name = clean_user_name(req.user.name); + let password = req.body.password; + let newpass = req.body.newpass; + LOG(req, "POST /change_password", name); + if (newpass.length < 4) { + req.flash('message', "Password is too short!"); + return res.redirect('/change_password'); + } + let salt_row = db.prepare("SELECT salt FROM users WHERE name = ?").get(name); + if (!salt_row) { + req.flash('message', "User not found."); + return res.redirect('/change_password'); + } + let salt = salt_row.salt; + let hash = hash_password(password, salt); + let user_row = db.prepare("SELECT user_id, name FROM users WHERE name = ? AND password = ?").get(name, hash); + if (!user_row) { + req.flash('message', "Wrong password."); + return res.redirect('/change_password'); + } + hash = hash_password(newpass, salt); + db.prepare("UPDATE users SET password = ? WHERE user_id = ?").run(hash, user_row.user_id); + return res.redirect('/profile'); + } catch (err) { + console.log(err); + req.flash('message', err.message); + return res.redirect('/change_password'); + } +}); + +/* + * GAME LOBBY + */ + +let RULES = {}; +for (let title_id of db.prepare("SELECT * FROM titles").pluck().all()) { + console.log("Loading rules for " + title_id); + try { + RULES[title_id] = require("./public/" + title_id + "/rules.js"); + } catch (err) { + console.log(err); + } +} + +const QUERY_LIST_ONE_GAME = db.prepare(` + SELECT + games.game_id, + games.title_id AS title_id, + titles.title_name AS title_name, + games.scenario AS scenario, + games.owner AS owner_id, + users.name AS owner_name, + games.ctime, + games.mtime, + games.description, + games.status, + games.result, + games.active + FROM games + JOIN users ON games.owner = users.user_id + JOIN titles ON games.title_id = titles.title_id + WHERE game_id = ? +`); + +const QUERY_LIST_PUBLIC_GAMES = db.prepare(` + SELECT + games.game_id, + games.title_id AS title_id, + games.scenario AS scenario, + games.owner AS owner_id, + users.name AS owner_name, + games.ctime, + games.mtime, + games.description, + games.status, + games.result, + games.active + FROM games + JOIN users ON games.owner = users.user_id + WHERE title_id = ? AND private = 0 + ORDER BY status ASC, mtime DESC +`); + +const QUERY_LIST_USER_GAMES = db.prepare(` + SELECT DISTINCT + games.game_id, + games.title_id, + titles.title_name, + games.scenario AS scenario, + users.name AS owner_name, + games.description, + games.ctime, + games.mtime, + games.status, + games.result, + games.active + FROM games + LEFT JOIN players ON games.game_id = players.game_id + LEFT JOIN users ON games.owner = users.user_id + LEFT JOIN titles ON games.title_id = titles.title_id + WHERE games.owner = ? OR players.user_id = ? + ORDER BY status ASC, mtime DESC +`); + +const QUERY_LIST_ALL_GAMES = db.prepare(` + SELECT + games.game_id, + games.title_id AS title_id, + titles.title_name, + games.scenario AS scenario, + games.owner AS owner_id, + users.name AS owner_name, + games.ctime, + games.mtime, + games.description, + games.status, + games.result, + games.active, + games.private + FROM games + JOIN users ON games.owner = users.user_id + LEFT JOIN titles ON games.title_id = titles.title_id + ORDER BY status ASC, mtime DESC +`); + +const QUERY_PLAYERS = db.prepare(` + SELECT + players.game_id, + players.user_id, + players.role, + users.name + FROM players + JOIN users ON players.user_id = users.user_id + WHERE players.game_id = ? +`); + +const QUERY_PLAYER_NAMES = db.prepare(` + SELECT + users.name AS name + FROM players + JOIN users ON players.user_id = users.user_id + WHERE players.game_id = ? + ORDER BY players.role +`).pluck(); + +const QUERY_TITLE = db.prepare("SELECT * FROM titles WHERE title_id = ?"); +const QUERY_ROLES = db.prepare("SELECT * FROM roles WHERE title_id = ?"); +const QUERY_GAME_OWNER = db.prepare("SELECT * FROM games WHERE game_id = ? AND owner = ?"); +const QUERY_TITLE_FROM_GAME = db.prepare("SELECT title_id FROM games WHERE game_id = ?"); +const QUERY_ROLE_FROM_GAME_AND_USER = db.prepare("SELECT role FROM players WHERE game_id = ? AND user_id = ?"); + +const QUERY_JOIN_GAME = db.prepare("INSERT INTO players (user_id, game_id, role) VALUES (?,?,?)"); +const QUERY_PART_GAME = db.prepare("DELETE FROM players WHERE game_id = ? AND user_id = ? AND role = ?"); +const QUERY_START_GAME = db.prepare("UPDATE games SET status = 1, state = ?, active = ? WHERE game_id = ?"); +const QUERY_CREATE_GAME = db.prepare(` + INSERT INTO games + (owner,title_id,scenario,private,ctime,mtime,description,status,state,chat) + VALUES + (?,?,?,?,datetime('now'),datetime('now'),?,0,NULL,'[]') +`); +const QUERY_UPDATE_GAME_SET_PRIVATE = db.prepare("UPDATE games SET private = 1 WHERE game_id = ?"); + +const QUERY_IS_PLAYER = db.prepare("SELECT COUNT(*) FROM players WHERE game_id = ? AND user_id = ?").pluck(); +const QUERY_IS_ACTIVE = db.prepare("SELECT COUNT(*) FROM players WHERE game_id = ? AND role = ? AND user_id = ?").pluck(); +const QUERY_COUNT_OPEN_GAMES = db.prepare("SELECT COUNT(*) FROM games WHERE owner = ? AND status = 0").pluck(); +const QUERY_DELETE_GAME = db.prepare("DELETE FROM games WHERE game_id = ?"); + +app.get('/', function (req, res) { + res.render('index.ejs', { user: req.user, message: req.flash('message') }); +}); + +function is_your_turn(game, user) { + if (!game.active || game.active == "None") + return false; + if (game.active == "All" || game.active == "Both") + return QUERY_IS_PLAYER.get(game.game_id, user.user_id); + return QUERY_IS_ACTIVE.get(game.game_id, game.active, user.user_id); +} + +app.get('/profile', must_be_logged_in, function (req, res) { + LOG(req, "GET /profile"); + let avatar = get_avatar(req.user.mail); + let games = QUERY_LIST_USER_GAMES.all(req.user.user_id, req.user.user_id); + humanize(games); + for (let game of games) { + game.players = QUERY_PLAYER_NAMES.all(game.game_id); + game.your_turn = is_your_turn(game, req.user); + } + let open_games = games.filter(game => game.status == 0); + let active_games = games.filter(game => game.status == 1); + let finished_games = games.filter(game => game.status == 2); + res.set("Cache-Control", "no-store"); + res.render('profile.ejs', { user: req.user, avatar: avatar, + open_games: open_games, + active_games: active_games, + finished_games: finished_games, + message: req.flash('message') + }); +}); + +app.get('/info/:title_id', function (req, res) { + LOG(req, "GET /info/" + req.params.title_id); + let title_id = req.params.title_id; + let title = QUERY_TITLE.get(title_id); + if (!title) { + req.flash('message', 'That title does not exist.'); + return res.redirect('/'); + } + if (req.isAuthenticated()) { + let games = QUERY_LIST_PUBLIC_GAMES.all(title_id); + humanize(games); + let open_games = games.filter(game => game.status == 0); + let active_games = games.filter(game => game.status == 1); + for (let game of active_games) { + game.players = QUERY_PLAYER_NAMES.all(game.game_id); + game.your_turn = is_your_turn(game, req.user); + } + let finished_games = games.filter(game => game.status == 2); + for (let game of finished_games) + game.players = QUERY_PLAYER_NAMES.all(game.game_id); + res.set("Cache-Control", "no-store"); + res.render('info.ejs', { user: req.user, title: title, + open_games: open_games, + active_games: active_games, + finished_games: finished_games, + message: req.flash('message') + }); + } else { + res.set("Cache-Control", "no-store"); + res.render('info.ejs', { user: req.user, title: title, + open_games: [], + active_games: [], + finished_games: [], + message: req.flash('message') + }); + } +}); + +app.get('/create/:title_id', must_be_logged_in, function (req, res) { + LOG(req, "GET /create/" + req.params.title_id); + let title_id = req.params.title_id; + let title = QUERY_TITLE.get(title_id); + if (!title) { + req.flash('message', 'That title does not exist.'); + return res.redirect('/'); + } + res.render('create.ejs', { user: req.user, message: req.flash('message'), title: title, scenarios: RULES[title_id].scenarios }); +}); + +app.post('/create/:title_id', must_be_logged_in, function (req, res) { + let title_id = req.params.title_id; + let descr = req.body.description; + let priv = req.body.private == 'private'; + let scenario = req.body.scenario; + let user_id = req.user.user_id; + LOG(req, "POST /create/" + req.params.title_id, scenario, priv, JSON.stringify(descr)); + try { + let count = QUERY_COUNT_OPEN_GAMES.get(user_id); + if (count >= MAX_OPEN_GAMES) { + req.flash('message', "You have too many open games!"); + return res.redirect('/create/'+title_id); + } + if (!(title_id in RULES)) { + req.flash('message', "That title doesn't exist."); + return res.redirect('/'); + } + if (!RULES[title_id].scenarios.includes(scenario)) { + req.flash('message', "That scenario doesn't exist."); + return res.redirect('/create/'+title_id); + } + let info = QUERY_CREATE_GAME.run(user_id, title_id, scenario, priv ? 1 : 0, descr); + res.redirect('/join/'+info.lastInsertRowid); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/create/'+title_id); + } +}); + +app.get('/delete/:game_id', must_be_logged_in, function (req, res) { + let game_id = req.params.game_id; + LOG(req, "GET /delete/" + game_id); + try { + let game = QUERY_GAME_OWNER.get(game_id, req.user.user_id); + if (!game) { + req.flash('message', "Only the game owner can delete the game!"); + return res.redirect('/join/'+game_id); + } + QUERY_DELETE_GAME.run(game_id); + res.redirect('/info/'+game.title_id); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/join/'+game_id); + } +}); + +app.get('/join/:game_id', must_be_logged_in, function (req, res) { + LOG(req, "GET /join/" + req.params.game_id); + let game_id = req.params.game_id | 0; + let game = QUERY_LIST_ONE_GAME.get(game_id); + if (!game) { + req.flash('message', "That game doesn't exist."); + return res.redirect('/'); + } + let roles = QUERY_ROLES.all(game.title_id); + let players = QUERY_PLAYERS.all(game_id); + res.set("Cache-Control", "no-store"); + res.render('join.ejs', { + user: req.user, + game: game, + roles: roles, + players: players, + solo: players.every(p => p.user_id == req.user.user_id), + message: req.flash('message') + }); +}); + +app.get('/join/:game_id/:role', must_be_logged_in, function (req, res) { + LOG(req, "GET /join/" + req.params.game_id + "/" + req.params.role); + let game_id = req.params.game_id | 0; + let role = req.params.role; + try { + QUERY_JOIN_GAME.run(req.user.user_id, game_id, role); + return res.redirect('/join/'+game_id); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/join/'+game_id); + } +}); + +app.get('/part/:game_id/:part_id/:role', must_be_logged_in, function (req, res) { + LOG(req, "GET /part/" + req.params.game_id + "/" + req.params.part_id + "/" + req.params.role); + let game_id = req.params.game_id | 0; + let part_id = req.params.part_id | 0; + let role = req.params.role; + try { + QUERY_PART_GAME.run(game_id, part_id, role); + return res.redirect('/join/'+game_id); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/join/'+game_id); + } +}); + +app.get('/start/:game_id', must_be_logged_in, function (req, res) { + LOG(req, "GET /start/" + req.params.game_id); + let game_id = req.params.game_id | 0; + try { + let game = QUERY_GAME_OWNER.get(game_id, req.user.user_id); + if (!game) { + req.flash('message', "Only the game owner can start the game!"); + return res.redirect('/join/'+game_id); + } + if (game.status != 0) { + req.flash('message', "The game is already started!"); + return res.redirect('/join/'+game_id); + } + let players = QUERY_PLAYERS.all(game_id); + let state = RULES[game.title_id].setup(game.scenario, players); + QUERY_START_GAME.run(JSON.stringify(state), state.active, game_id); + let is_solo = players.every(p => p.user_id == players[0].user_id); + if (is_solo) + QUERY_UPDATE_GAME_SET_PRIVATE.run(game_id); + return res.redirect('/join/'+game_id); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/join/'+game_id); + } +}); + +app.get('/play/:game_id/:role', must_be_logged_in, function (req, res) { + LOG(req, "GET /play/" + req.params.game_id + "/" + req.params.role); + let game_id = req.params.game_id | 0; + let role = req.params.role; + try { + let title = QUERY_TITLE_FROM_GAME.get(game_id); + if (!title) + return res.redirect('/join/'+game_id); + res.redirect('/'+title.title_id+'/play.html?game='+game_id+'&role='+role); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/join/'+game_id); + } +}); + +app.get('/play/:game_id', must_be_logged_in, function (req, res) { + LOG(req, "GET /play/" + req.params.game_id); + let game_id = req.params.game_id | 0; + let user_id = req.user.user_id | 0; + try { + let role = QUERY_ROLE_FROM_GAME_AND_USER.get(game_id, user_id); + if (!role) + return res.redirect('/play/'+game_id+'/Observer'); + return res.redirect('/play/'+game_id+'/'+role.role); + } catch (err) { + req.flash('message', err.toString()); + return res.redirect('/join/'+game_id); + } +}); + +/* + * GAME PLAYING + */ + +const QUERY_SELECT_CHAT = db.prepare("SELECT chat FROM games WHERE game_id = ?"); +const QUERY_UPDATE_CHAT = db.prepare("UPDATE games SET chat = ? WHERE game_id = ?"); +const QUERY_SELECT_GAME_STATE = db.prepare("SELECT state FROM games WHERE game_id = ?"); +const QUERY_UPDATE_GAME_STATE = db.prepare("UPDATE games SET state = ?, active = ?, status = ?, result = ?, mtime = datetime('now') WHERE game_id = ?"); +const QUERY_CONNECT_GAME = db.prepare("SELECT title_id, state FROM games WHERE title_id = ? AND game_id = ?"); +const QUERY_RESTART_GAME = db.prepare("UPDATE games SET state = ?, mtime = datetime('now') WHERE game_id = ?"); + +let clients = {}; + +function send_state(socket, state) { + try { + let view = socket.rules.view(state, socket.role); + if (socket.log_length < view.log.length) + view.log_start = socket.log_length; + else + view.log_start = view.log.length; + socket.log_length = view.log.length; + view.log = view.log.slice(view.log_start); + socket.emit('state', view); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function get_game_state(game_id) { + let row = QUERY_SELECT_GAME_STATE.get(game_id); + if (!row) + throw new Error("No game with that ID"); + return JSON.parse(row.state); +} + +function put_game_state(game_id, state) { + let status = 1; + let result = null; + if (state.state == 'game_over') { + status = 2; + result = state.result; + } + QUERY_UPDATE_GAME_STATE.run(JSON.stringify(state), state.active, status, result, game_id); + for (let other of clients[game_id]) + send_state(other, state); +} + +function on_action(socket, action, arg) { + SLOG(socket, "--> ACTION", action, arg); + try { + let state = get_game_state(socket.game_id); + socket.rules.action(state, socket.role, action, arg); + put_game_state(socket.game_id, state); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function on_resign(socket) { + SLOG(socket, "--> RESIGN"); + try { + let state = get_game_state(socket.game_id); + socket.rules.resign(state, socket.role); + put_game_state(socket.game_id, state); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function send_chat(socket, chat) { + if (chat && socket.chat_length < chat.length) { + SLOG(socket, "<-- CHAT LOG", socket.chat_length, "..", chat.length); + socket.emit('chat', socket.chat_length, chat.slice(socket.chat_length)); + socket.chat_length = chat.length; + } +} + +function on_getchat(socket, old_len) { + try { + socket.chat_length = old_len; + let row = QUERY_SELECT_CHAT.get(socket.game_id); + if (!row) + return socket.emit('error', "No game with that ID."); + let chat = JSON.parse(row.chat); + if (!chat) + chat = []; + send_chat(socket, chat); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function on_chat(socket, message) { + message = message.substring(0,4096); + SLOG(socket, "--> CHAT"); + try { + let row = QUERY_SELECT_CHAT.get(socket.game_id); + if (!row) + return socket.emit('error', "No game with that ID."); + let chat = JSON.parse(row.chat); + if (!chat) + chat = []; + chat.push([new Date(), socket.user_name, message]); + QUERY_UPDATE_CHAT.run(JSON.stringify(chat), socket.game_id); + for (let other of clients[socket.game_id]) + send_chat(other, chat); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function on_debug(socket) { + SLOG(socket, "<-- DEBUG"); + try { + let row = QUERY_SELECT_GAME_STATE.get(socket.game_id); + if (!row) + return socket.emit('error', "No game with that ID."); + socket.emit('debug', row.state); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function on_save(socket) { + SLOG(socket, "<-- SAVE"); + try { + let row = QUERY_SELECT_GAME_STATE.get(socket.game_id); + if (!row) + return socket.emit('error', "No game with that ID."); + socket.emit('save', row.state); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function on_restore(socket, state_text) { + SLOG(socket, '--> RESTORE', state_text); + try { + let state = JSON.parse(state_text); + QUERY_UPDATE_GAME_STATE.run(state_text, state.active, 1, null, socket.game_id); + for (let other of clients[socket.game_id]) + send_state(other, state); + } catch (err) { + console.log(err); + return socket.emit('error', err.toString()); + } +} + +function broadcast_presence(game_id) { + let presence = {}; + for (let socket of clients[game_id]) + presence[socket.role] = true; + for (let socket of clients[game_id]) + socket.emit('presence', presence); +} + +io.on('connection', (socket) => { + socket.title_id = socket.handshake.query.title; + socket.game_id = socket.handshake.query.game | 0; + socket.user_id = socket.request.user.user_id | 0; + socket.user_name = socket.request.user.name; + socket.role = socket.handshake.query.role; + socket.log_length = 0; + socket.chat_length = 0; + socket.rules = RULES[socket.title_id]; + + SLOG(socket, "CONNECT"); + + try { + let game = QUERY_CONNECT_GAME.get(socket.title_id, socket.game_id); + if (!game) + return socket.emit('error', "That game does not exist."); + + let players = QUERY_PLAYERS.all(socket.game_id); + + if (socket.role != "Observer") { + let me; + if (socket.role && socket.role != 'undefined' && socket.role != 'null') { + me = players.find(p => p.user_id == socket.user_id && p.role == socket.role); + if (!me) { + socket.role = "Observer"; + return socket.emit('error', "You aren't assigned that role!"); + } + } else { + me = players.find(p => p.user_id == socket.user_id); + socket.role = me ? me.role : "Observer"; + } + } + + socket.emit('roles', socket.role, players); + + if (clients[socket.game_id]) + clients[socket.game_id].push(socket); + else + clients[socket.game_id] = [ socket ]; + + socket.on('disconnect', () => { + SLOG(socket, "DISCONNECT"); + clients[socket.game_id].splice(clients[socket.game_id].indexOf(socket), 1); + if (socket.role != "Observer") + broadcast_presence(socket.game_id); + }); + + if (socket.role != "Observer") { + socket.on('action', (action, arg) => on_action(socket, action, arg)); + socket.on('resign', () => on_resign(socket)); + socket.on('getchat', (old_len) => on_getchat(socket, old_len)); + socket.on('chat', (message) => on_chat(socket, message)); + + socket.on('debug', () => on_debug(socket)); + socket.on('save', () => on_save(socket)); + socket.on('restore', (state) => on_restore(socket, state)); + socket.on('restart', (scenario) => { + let state = socket.rules.setup(scenario, players); + for (let other of clients[socket.game_id]) { + other.log_length = 0; + send_state(other, state); + } + let state_text = JSON.stringify(state); + QUERY_RESTART_GAME.run(state_text, socket.game_id); + }); + } + + broadcast_presence(socket.game_id); + + send_state(socket, JSON.parse(game.state)); + + } catch (err) { + console.log(err); + socket.emit('error', err.message); + } +}); + +http.listen(http_port, '0.0.0.0', () => { console.log('listening HTTP on *:' + http_port); }); +https.listen(https_port, '0.0.0.0', () => { console.log('listening HTTPS on *:' + https_port); }); diff --git a/tools/editgame.sh b/tools/editgame.sh new file mode 100644 index 0000000..7f21fcb --- /dev/null +++ b/tools/editgame.sh @@ -0,0 +1,8 @@ +#!/bin/bash +if [ -n "$1" -a -n "$VISUAL" ] +then + sqlite3 db "update games set state=edit(state) where game_id = $1" +else + echo "usage: bash tools/editgame.sh GAME" + echo "note: \$VISUAL must be set to your preferred editor" +fi diff --git a/tools/makecert.sh b/tools/makecert.sh new file mode 100644 index 0000000..94d8bb7 --- /dev/null +++ b/tools/makecert.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# +# Create a simple self-signed SSL certificate. +# + +openssl req -nodes -new -x509 -keyout key.pem -out cert.pem diff --git a/tools/purge.sh b/tools/purge.sh new file mode 100644 index 0000000..8c1ea7e --- /dev/null +++ b/tools/purge.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# Clean out stale games from the database. + +sqlite3 db "DELETE FROM games WHERE status = 0 AND mtime < datetime('now', '-7 days')" +sqlite3 db "UPDATE games SET status = 3 WHERE status = 1 AND mtime < datetime('now', '-28 days')" diff --git a/tools/readgame.sh b/tools/readgame.sh new file mode 100644 index 0000000..5bf93de --- /dev/null +++ b/tools/readgame.sh @@ -0,0 +1,10 @@ +#!/bin/bash +if [ -n "$1" -a -n "$2" ] +then + sqlite3 db "select writefile('$2',state) from games where game_id = $1" +elif [ -n "$1" ] +then + sqlite3 db "select state from games where game_id = $1" +else + echo "usage: bash tools/readgame.sh GAME [ state.json ]" +fi diff --git a/tools/sql/schema.txt b/tools/sql/schema.txt new file mode 100644 index 0000000..97e8b25 --- /dev/null +++ b/tools/sql/schema.txt @@ -0,0 +1,86 @@ +CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + name TEXT UNIQUE, + mail TEXT UNIQUE, + password TEXT, + salt TEXT, + ctime TIMESTAMP, + cip TEXT, + atime TIMESTAMP, + aip TEXT +); + +CREATE TABLE IF NOT EXISTS blacklist_ip ( ip TEXT PRIMARY KEY ); +CREATE TABLE IF NOT EXISTS blacklist_mail ( mail TEXT PRIMARY KEY ); + +CREATE TABLE IF NOT EXISTS titles ( + title_id TEXT UNIQUE, + title_name TEXT, + bgg INTEGER +); + +CREATE TABLE IF NOT EXISTS roles ( + title_id TEXT, + role TEXT, + UNIQUE ( title_id, role ) +); + +CREATE TABLE IF NOT EXISTS games ( + game_id INTEGER PRIMARY KEY, + title_id TEXT, + scenario TEXT, + owner INTEGER, + private BOOLEAN, + ctime TIMESTAMP, + mtime TIMESTAMP, + description TEXT, + status INTEGER, + active TEXT, + result TEXT, + state TEXT, + chat TEXT +); + +CREATE TABLE IF NOT EXISTS players ( + user_id INTEGER, + game_id INTEGER, + role TEXT, + UNIQUE ( game_id, user_id, role ), + UNIQUE ( game_id, role ) +); + +DROP TRIGGER IF EXISTS no_part_on_active_game; +CREATE TRIGGER no_part_on_active_game BEFORE DELETE ON players +BEGIN + SELECT CASE + WHEN ( SELECT status FROM games WHERE game_id = old.game_id ) > 0 + THEN RAISE(ABORT, "Cannot remove players from started games.") + END; +END; + +DROP TRIGGER IF EXISTS no_join_on_active_game; +CREATE TRIGGER no_join_on_active_game BEFORE INSERT ON players +BEGIN + SELECT CASE + WHEN ( SELECT status FROM games WHERE game_id = new.game_id ) > 0 + THEN RAISE(ABORT, "Cannot add players to started games.") + END; +END; + +DROP TRIGGER IF EXISTS must_be_valid_role; +CREATE TRIGGER must_be_valid_role BEFORE INSERT ON players +BEGIN + SELECT CASE + WHEN ( SELECT COUNT(*) FROM roles, games WHERE + roles.title_id = games.title_id AND + games.game_id = new.game_id AND + roles.role = new.role ) <> 1 + THEN RAISE(ABORT, "Invalid role for that title.") + END; +END; + +DROP TRIGGER IF EXISTS purge_players; +CREATE TRIGGER purge_players AFTER DELETE ON games +BEGIN + DELETE FROM players WHERE game_id = old.game_id; +END; diff --git a/tools/start.sh b/tools/start.sh new file mode 100644 index 0000000..eb5240a --- /dev/null +++ b/tools/start.sh @@ -0,0 +1,2 @@ +#!/bin/bash +forever start -a --uid rally --killSignal=SIGTERM -c 'nodemon --exitcrash' server.js diff --git a/tools/stop.sh b/tools/stop.sh new file mode 100644 index 0000000..39f920a --- /dev/null +++ b/tools/stop.sh @@ -0,0 +1,2 @@ +#!/bin/bash +forever stop rally diff --git a/tools/writegame.sh b/tools/writegame.sh new file mode 100644 index 0000000..82cff23 --- /dev/null +++ b/tools/writegame.sh @@ -0,0 +1,7 @@ +#!/bin/bash +if [ -n "$1" -a -f "$2" ] +then + sqlite3 db "update games set state=readfile('$2') where game_id = $1" +else + echo "usage: bash tools/writegame.sh GAME state.json" +fi diff --git a/views/about.ejs b/views/about.ejs new file mode 100644 index 0000000..ed1b54d --- /dev/null +++ b/views/about.ejs @@ -0,0 +1,83 @@ +<%- include('header', { title: "Rally the Troops!" }) %> + + +

+Rally the Troops! is created and maintained by Tor Andersson. +It is an open source project, and you can find the code on GitHub. + +

+Tips & Tricks +

+ +
    + +
  • +Open a separate browser tab or window for each side when playing solo. + +
  • +Use the middle mouse button to drag and scroll around the map. + +
  • +The Enter and Escape keys open and close the chat box. + +
  • +To invite your friends to a private game, send them the address of the join page. + +
  • +Chat messages can only be seen by players who are part of a game. +They are hidden from observers. + +
  • +The menu has links to rules, player aids and other reference material. +In some games you can also choose between alternative graphics and layout options. + +
  • +The button hides all counters and markers, +if you need to check something on the map that is obscured. + +
  • +The button hides the game log and player status displays, so you can +see more of the map. + +
  • +The button lights up if you have unread chat messages. + +
+ +

+Licensing +

+ +

+All games are used with consent from their respective rights holders. + +

+Icons are sourced from game-icons.net +by Delapouite, Lorc, and others under the +CC BY 3.0 license. + + diff --git a/views/banned.ejs b/views/banned.ejs new file mode 100644 index 0000000..4c08bc2 --- /dev/null +++ b/views/banned.ejs @@ -0,0 +1,3 @@ +<%- include('header', { title: "Banned" }) %> +

+Sorry, but this IP or account has been banned. diff --git a/views/change_password.ejs b/views/change_password.ejs new file mode 100644 index 0000000..ab15a4a --- /dev/null +++ b/views/change_password.ejs @@ -0,0 +1,15 @@ +<%- include('header', { title: "Change password" }) %> +

+

+Name: <%= user.name %> +

+Mail: <%= user.mail %> +

+
+ +

+
+ +

+ +

diff --git a/views/create.ejs b/views/create.ejs new file mode 100644 index 0000000..a23fab5 --- /dev/null +++ b/views/create.ejs @@ -0,0 +1,22 @@ +<%- include('header', { title: title.title_name }) %> + + +
+

+Scenario:
+ +

+Description:
+ +

+ +

+ +

diff --git a/views/error.ejs b/views/error.ejs new file mode 100644 index 0000000..b7d9632 --- /dev/null +++ b/views/error.ejs @@ -0,0 +1 @@ +<%- include('header', { title: "Error" }) %> diff --git a/views/header.ejs b/views/header.ejs new file mode 100644 index 0000000..9e101dd --- /dev/null +++ b/views/header.ejs @@ -0,0 +1,32 @@ + + + + +<% if (typeof refresh != 'undefined' && refresh > 0) { %><% } %> + + + +<%= title %> + + +
+
+
+About +<% + if (user) { + %>Profile (<%= user.name %>)<% + } else { + %>Signup<% + %>Login<% + } +%> +
+
+
+

<%= title %>

+<% + if (typeof message != 'undefined' && message.length > 0) { + %>

<%= Array.isArray(message) ? message.join("\n") : message %>

<% + } +%> diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..a9abeff --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,19 @@ +<%- include('header', { title: "Rally the Troops!" }) %> + + +

+Rally the Troops! is a website where you can play historic games with other +players. + +

+Registration and use is free, and there are no ads. + +

+
+ +

+Join the Discord server to find players or report bugs. diff --git a/views/info.ejs b/views/info.ejs new file mode 100644 index 0000000..27cb543 --- /dev/null +++ b/views/info.ejs @@ -0,0 +1,83 @@ +<%- include('header', { title: title.title_name, refresh: (user ? 300 : 0) }) %> + +<%- include('../public/' + title.title_id + '/about.html') %> +
+

+Read more about the game on +boardgamegeek.com. + +<% if (user) { %> + +

Open Games

+ + +
IDScenarioOwnerDescriptionCreated +<% if (open_games.length > 0) { %> +<% open_games.forEach((row) => { %> +
<%= row.game_id %> +<%= row.scenario %> +<%= row.owner_name %> +<%= row.description %> +<%= row.ctime %> +Join +<% }); } else { %> +
No open games. +<% } %> +
+ +

+Create a new game. + +<% if (active_games.length > 0) { %> +

Active Games

+ + +
IDScenarioPlayersDescriptionChangedTurn +<% active_games.forEach((row) => { %> +
<%= row.game_id %> +<%= row.scenario %> +<%= row.players.join(", ") %> +<%= row.description %> +<%= row.mtime %> +<% + if (row.your_turn) { + %><%= row.active %><% + } else { + %><%= row.active %><% + } + let me = row.players.reduce((n,p) => n + (p === user.name ? 1 : 0), 0); + if (me == 1) { + %>Play<% + } else if (me > 1) { + %>Play<% + } else { + %>View<% + } +%> +<% }); %> +
+<% } %> + +<% if (finished_games.length > 0) { %> +

Finished Games

+ + +
IDScenarioPlayersDescriptionFinishedResult +<% finished_games.forEach((row) => { %> +
<%= row.game_id %> +<%= row.scenario %> +<%= row.players.join(", ") %> +<%= row.description %> +<%= row.mtime %> +<%= row.result %> +View +<% }); %> +
+<% } %> + +<% } else { %> + +

+Login to create or join a game. + +<% } %> diff --git a/views/join.ejs b/views/join.ejs new file mode 100644 index 0000000..810e0f4 --- /dev/null +++ b/views/join.ejs @@ -0,0 +1,85 @@ +<%- include('header', { title: game.title_name, refresh: game.status == 0 ? 15 : 0 }) %> + + + + +

+Owner: <%= game.owner_name %> +

+Scenario: <%= game.scenario %> +

+Description: <%= game.description || "No description." %> + +
+ +

+ + +<% + roles.forEach((role) => { + %> +<% + roles.forEach((role) => { + if (game.active == role.role || game.active == "Both" || game.active == "All") { + %>
<%= role.role %><% + }); +%> +
<% + } else { + %><% + } + let p = players.find(p => p.role == role.role); + if (game.status == 0) { + if (p) { + if ((p.user_id == user.user_id) || (game.owner_id == user.user_id)) { + %> <% + %><%= p.name %><% + } else { + %><%= p.name %><% + } + } else { + %>Join<% + } + } else { + if (p) { + if (p.user_id == user.user_id) { + %>Play<% + } else { + %><%= p.name %><% + } + } else { + %>Empty<% + } + } + }); + if (game.status > 0 && !players.some(p => p.user_id == user.user_id)) { + %> +
View + <% + } +%> +
+ +

+<% + if (game.status == 0) { + if (players.length == roles.length) { + if (game.owner_id == user.user_id) { + %>

<% + } else { + %>Waiting for <%= game.owner_name %> to start the game.<% + } + } else { + %>Waiting for players to join the game.<% + } + } + if (game.owner_id == user.user_id && (game.status == 0 || solo)) { + %>


<% + } +%> diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..a5e2546 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,15 @@ +<%- include('header', { title: "Login" }) %> +<% if (user) { %> +

You're already logged in! +<% } else { %> +

+

+
+ +

+
+ +

+ +

+<% } %> diff --git a/views/profile.ejs b/views/profile.ejs new file mode 100644 index 0000000..60f1ff9 --- /dev/null +++ b/views/profile.ejs @@ -0,0 +1,84 @@ +<%- include('header', { title: "Rally the Troops!", refresh: (active_games.length > 0 ? 300 : 0) }) %> + + + +

+Welcome, <%= user.name %>! +

+Your mail address is <%= user.mail %>. + +
+ +

+Change password + +

+Logout + +<% if (open_games.length > 0) { %> +

Open Games

+ + +
IDGameScenarioPlayersDescriptionCreated +<% open_games.forEach((row) => { %> +
<%= row.game_id %> +<%= row.title_name %> +<%= row.scenario %> +<%= row.players.join(", ") %> +<%= row.description %> +<%= row.ctime %> +Join +<% }); %> +
+<% } %> + +<% if (active_games.length > 0) { %> +

Active Games

+ + +
IDGameScenarioPlayersDescriptionChangedTurn +<% active_games.forEach((row) => { %> +
<%= row.game_id %> +<%= row.title_name %> +<%= row.scenario %> +<%= row.players.join(", ") %> +<%= row.description %> +<%= row.mtime %> +<% + if (row.your_turn) { + %><%= row.active %><% + } else { + %><%= row.active %><% + } + if (row.players.reduce((n,p) => n + (p === user.name ? 1 : 0), 0) == 1) { + %>Play<% + } else { + %>Play<% + } +%> +<% }); %> +
+<% } %> + +<% if (finished_games.length > 0) { %> +

Finished Games

+ + +
IDGameScenarioPlayersDescriptionFinishedResult +<% finished_games.forEach((row) => { %> +
<%= row.game_id %> +<%= row.title_name %> +<%= row.scenario %> +<%= row.players.join(", ") %> +<%= row.description %> +<%= row.mtime %> +<%= row.result %> +View +<% }); %> +
+<% } %> + +<% if (open_games.length == 0 && active_games.length == 0 && finished_games.length == 0) { %> +

+You don't have any current or finished games. +<% } %> diff --git a/views/signup.ejs b/views/signup.ejs new file mode 100644 index 0000000..819da3f --- /dev/null +++ b/views/signup.ejs @@ -0,0 +1,18 @@ +<%- include('header', { title: "Signup" }) %> +<% if (user) { %> +

You're already logged in! +<% } else { %> +

+

+
+ +

+
+ +

+
+ +

+ +

+<% } %> diff --git a/views/users.ejs b/views/users.ejs new file mode 100644 index 0000000..a8f5c7d --- /dev/null +++ b/views/users.ejs @@ -0,0 +1,16 @@ +<%- include('header', { title: "User list" }) %> + + + + +
AvatarNameMember sinceLast seen + +<% userList.forEach((row) => { %> +
+<%= row.name %> +<%= row.ctime %> +<%= row.atime %> +<% }); %> -- cgit v1.2.3