├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── media └── active_polls.PNG ├── package.json ├── src ├── index.js ├── middleware │ └── asyncMiddleware.js ├── repositories │ └── pollsRepository.js ├── routes │ ├── auth.js │ ├── polls.js │ └── views.js └── utils │ ├── db.js │ ├── hash.js │ ├── path.js │ └── polls.js ├── views ├── admin-active.njk ├── admin-closed.njk ├── admin-create.njk ├── admin.njk ├── footer.njk ├── head.njk ├── index.njk ├── js │ └── element.js ├── login.njk ├── macros │ ├── admin-tab.html │ ├── hero.html │ └── pollCard.html └── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── bulma.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | media 3 | 4 | *.log -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | db 3 | 4 | *.log -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.16 2 | ENV NODE_ENV production 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY package.json ./ 7 | COPY yarn.lock ./ 8 | RUN yarn 9 | COPY . . 10 | 11 | 12 | EXPOSE 3000 13 | CMD [ "node", "src/index.js" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MOB 2 | 3 | MOB is a simple polling application aimed at getting quick feedback from users. 4 | 5 | ### Info 6 | 7 | MOB uses the `MOB_LOGIN_PASSWORD` environment variable to set the password for admin functionality. Assign this as a secret to your running container. 8 | 9 | #### Goals 10 | 11 | - Easily deployable (ie. single self contained Docker container) 12 | - Simple 13 | - Lightweight 14 | - On-prem 15 | 16 |  17 | -------------------------------------------------------------------------------- /media/active_polls.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ganderzz/mob/6b12dd49e66eb4e2b7c925ef562b23c58adc8183/media/active_polls.PNG -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mob", 3 | "version": "1.0.0", 4 | "main": "src/index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "compression": "^1.7.4", 8 | "cookie-parser": "^1.4.4", 9 | "express": "^4.17.0", 10 | "forever": "^1.0.0", 11 | "helmet": "^3.18.0", 12 | "nunjucks": "^3.2.0", 13 | "sqlite3": "^4.0.8" 14 | }, 15 | "devDependencies": { 16 | "nodemon": "^1.19.1", 17 | "prettier": "^1.17.1" 18 | }, 19 | "scripts": { 20 | "start": "node_modules/.bin/nodemon ./src/index.js" 21 | }, 22 | "nodemonConfig": { 23 | "ignore": [ 24 | "views/*", 25 | "db/*" 26 | ], 27 | "verbose": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const fs = require("fs"); 3 | const nunjucks = require("nunjucks"); 4 | const PollsRepository = require("./repositories/pollsRepository"); 5 | const compression = require("compression"); 6 | const cookieParser = require("cookie-parser"); 7 | const path = require("path"); 8 | const helmet = require("helmet"); 9 | const { BASE_URL } = require("./utils/path"); 10 | 11 | const app = express(); 12 | 13 | app.use(helmet()); 14 | 15 | app.use(compression()); 16 | app.use(cookieParser()); 17 | app.use(express.json()); 18 | 19 | app.use("/public", express.static(path.resolve(BASE_URL, "views", "public"))); 20 | 21 | const http = require("http").createServer(app); 22 | 23 | nunjucks.configure("views", { 24 | autoescape: false, 25 | watch: process.env.NODE_ENV !== "production", 26 | express: app 27 | }); 28 | 29 | require("./routes/polls")(app); 30 | require("./routes/views")(app); 31 | require("./routes/auth")(app); 32 | 33 | const PORT = process.env.PORT || 3000; 34 | 35 | http.listen(PORT, async () => { 36 | const databaseDirectory = process.env.DB_PATH || "./db"; 37 | 38 | // Create DB directory if it doesn't exist 39 | if (!fs.existsSync(databaseDirectory)) { 40 | fs.mkdirSync(databaseDirectory); 41 | } 42 | 43 | const repo = new PollsRepository(); 44 | await repo.init(); 45 | 46 | console.log("listening on localhost:" + PORT); 47 | }); 48 | -------------------------------------------------------------------------------- /src/middleware/asyncMiddleware.js: -------------------------------------------------------------------------------- 1 | const asyncMiddleware = fn => (req, res, next) => { 2 | res.setHeader("Content-Type", "application/json"); 3 | 4 | Promise.resolve(fn(req, res, next)).catch(function(error) { 5 | res.status(error.status || 500); 6 | res.setHeader("Content-Type", "application/json"); 7 | res.send(JSON.stringify({ message: error.message })); 8 | 9 | next(); 10 | }); 11 | }; 12 | 13 | module.exports = asyncMiddleware; 14 | -------------------------------------------------------------------------------- /src/repositories/pollsRepository.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const sqlite3 = require("sqlite3").verbose(); 3 | const { BASE_URL } = require("../utils/path"); 4 | const { runAsync } = require("../utils/db"); 5 | 6 | class PollsRepository { 7 | constructor() { 8 | this.DB = new sqlite3.Database(path.resolve(BASE_URL, "db", "polls.db")); 9 | } 10 | 11 | close() { 12 | if (this.DB) { 13 | this.DB.close(); 14 | } 15 | } 16 | 17 | async init() { 18 | if (!this.DB) { 19 | throw new Error("Database is not initialized"); 20 | } 21 | 22 | // ----- Tables ----- 23 | 24 | await runAsync( 25 | this.DB, 26 | `CREATE TABLE IF NOT EXISTS polls ( 27 | id INTEGER PRIMARY KEY, 28 | question TEXT, 29 | isActive INTEGER 30 | )` 31 | ); 32 | 33 | await runAsync( 34 | this.DB, 35 | `CREATE TABLE IF NOT EXISTS options ( 36 | id INTEGER PRIMARY KEY, 37 | pollId INTEGER, 38 | value TEXT, 39 | totalVotes INTEGER, 40 | FOREIGN KEY(pollId) REFERENCES polls(id) 41 | )` 42 | ); 43 | 44 | // ----- Indicies ----- 45 | 46 | await runAsync( 47 | this.DB, 48 | `CREATE INDEX IF NOT EXISTS idx_pollid ON options(pollId)` 49 | ); 50 | } 51 | 52 | getActivePolls() { 53 | const sql = ` 54 | SELECT * FROM polls p 55 | LEFT JOIN options as o ON o.pollId = p.id 56 | WHERE p.isActive = 1 57 | `; 58 | 59 | return new Promise(resolve => { 60 | this.DB.all(sql, [], (error, rows) => { 61 | if (error) { 62 | return new Error(error); 63 | } 64 | 65 | const groupedByPoll = PollsRepository.groupRows(rows); 66 | 67 | return resolve( 68 | Object.keys(groupedByPoll).map(key => groupedByPoll[key]) 69 | ); 70 | }); 71 | }); 72 | } 73 | 74 | getClosedPolls() { 75 | const sql = ` 76 | SELECT * FROM polls p 77 | LEFT JOIN options as o ON o.pollId = p.id 78 | WHERE p.isActive = 0 79 | `; 80 | 81 | return new Promise(resolve => { 82 | this.DB.all(sql, [], (error, rows) => { 83 | if (error) { 84 | return new Error(error); 85 | } 86 | 87 | const groupedByPoll = PollsRepository.groupRows(rows); 88 | 89 | return resolve( 90 | Object.keys(groupedByPoll).map(key => groupedByPoll[key]) 91 | ); 92 | }); 93 | }); 94 | } 95 | 96 | openPoll(pollId) { 97 | if (pollId == null) { 98 | return Promise.reject("Invalid Poll ID."); 99 | } 100 | 101 | return new Promise(resolve => { 102 | this.DB.run( 103 | `UPDATE polls SET isActive = 1 WHERE id = ?`, 104 | [pollId], 105 | function() { 106 | resolve(true); 107 | } 108 | ); 109 | }); 110 | } 111 | 112 | closePoll(pollId) { 113 | if (pollId == null) { 114 | return Promise.reject("Invalid Poll ID."); 115 | } 116 | 117 | return new Promise(resolve => { 118 | this.DB.run( 119 | `UPDATE polls SET isActive = 0 WHERE id = ?`, 120 | [pollId], 121 | function() { 122 | resolve(true); 123 | } 124 | ); 125 | }); 126 | } 127 | 128 | updateOptionCount(optionId) { 129 | if (optionId == null || optionId == undefined) { 130 | return Promise.reject("Invalid option id provided."); 131 | } 132 | 133 | return new Promise(resolve => { 134 | this.DB.run( 135 | ` 136 | UPDATE options 137 | SET totalVotes = totalVotes + 1 138 | WHERE id = ? 139 | `, 140 | [optionId], 141 | function() { 142 | resolve(); 143 | } 144 | ); 145 | }); 146 | } 147 | 148 | getPollById(id) { 149 | if (id == null || id == undefined) { 150 | return Promise.reject("Invalid ID provided."); 151 | } 152 | 153 | const sql = ` 154 | SELECT * FROM polls as p 155 | LEFT JOIN options as o ON o.pollId = p.id 156 | WHERE p.id = ? 157 | `; 158 | 159 | return new Promise(resolve => { 160 | this.DB.all(sql, [id], (error, rows) => { 161 | if (error) { 162 | throw new Error(error); 163 | } 164 | 165 | const groupedByPoll = PollsRepository.groupRows(rows); 166 | 167 | return resolve( 168 | Object.keys(groupedByPoll).map(key => groupedByPoll[key])[0] 169 | ); 170 | }); 171 | }); 172 | } 173 | 174 | /** 175 | * Create Poll using the provided questions and options 176 | * 177 | * @param {*} poll 178 | */ 179 | createPoll(poll) { 180 | return new Promise(resolve => { 181 | if (!poll || !poll.question) { 182 | throw new Error("Poll is missing a question."); 183 | } 184 | 185 | if (!poll.options || poll.options.length < 1) { 186 | throw new Error("A poll must be created with options."); 187 | } 188 | 189 | const db = this.DB; 190 | 191 | db.run( 192 | `INSERT INTO polls 193 | (question, isActive) 194 | VALUES (?, 1) 195 | `, 196 | [poll.question], 197 | function(err) { 198 | if (err) { 199 | throw new Error(err); 200 | } 201 | 202 | const pollId = this.lastID; 203 | 204 | poll.options.forEach(val => { 205 | db.run( 206 | `INSERT INTO options 207 | (pollId, value, totalVotes) 208 | VALUES (?, ?, 0) 209 | `, 210 | [pollId, val] 211 | ); 212 | }); 213 | 214 | resolve(pollId); 215 | } 216 | ); 217 | }); 218 | } 219 | 220 | static groupRows(rows) { 221 | if (!rows) { 222 | return []; 223 | } 224 | 225 | return rows.reduce((accu, current) => { 226 | return { 227 | ...accu, 228 | [current.pollId]: { 229 | ...(accu[current.pollId] || {}), 230 | id: current.pollId, 231 | question: current.question, 232 | isActive: current.isActive || 0, 233 | options: [ 234 | ...(accu[current.pollId] ? accu[current.pollId].options : []), 235 | { 236 | id: current.id, 237 | value: current.value, 238 | totalVotes: current.totalVotes 239 | } 240 | ] 241 | } 242 | }; 243 | }, {}); 244 | } 245 | } 246 | 247 | module.exports = PollsRepository; 248 | -------------------------------------------------------------------------------- /src/routes/auth.js: -------------------------------------------------------------------------------- 1 | const asyncMiddleware = require("../middleware/asyncMiddleware"); 2 | const { getGuid } = require("../utils/hash"); 3 | 4 | module.exports = function(app) { 5 | app.post( 6 | "/api/login", 7 | asyncMiddleware(async (req, res) => { 8 | if (!req.body) { 9 | throw new Error("Invalid password given."); 10 | } 11 | 12 | if (res.cookies && res.cookies.auth) { 13 | throw new Error("Already logged in"); 14 | } 15 | 16 | const pass = req.body.value; 17 | 18 | if (pass === (process.env.MOB_LOGIN_PASSWORD || "MOB")) { 19 | res.cookie("auth", getGuid(), { maxAge: 24 * 60 * 60 * 60 }); 20 | } else { 21 | throw new Error("Invalid password given."); 22 | } 23 | 24 | res.send(JSON.stringify(true)); 25 | }) 26 | ); 27 | 28 | app.post( 29 | "/api/logout", 30 | asyncMiddleware(async (req, res) => { 31 | if (!req.cookies || !req.cookies.auth) { 32 | throw new Error("Already logged out."); 33 | } 34 | 35 | res.cookie("auth", "-1", { maxAge: new Date(0) }); 36 | res.send(true); 37 | }) 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/routes/polls.js: -------------------------------------------------------------------------------- 1 | const asyncMiddleware = require("../middleware/asyncMiddleware"); 2 | const PollsRepository = require("../repositories/pollsRepository"); 3 | 4 | module.exports = function(app) { 5 | app.get( 6 | "/api/polls", 7 | asyncMiddleware(async (req, res) => { 8 | const result = await new PollsRepository().getActivePolls(); 9 | 10 | res.send(JSON.stringify(result)); 11 | }) 12 | ); 13 | 14 | app.get( 15 | "/api/polls/:id", 16 | asyncMiddleware(async (req, res) => { 17 | if (!req.params || !req.params.id) { 18 | throw new Error("Invalid id provided."); 19 | } 20 | 21 | const result = await new PollsRepository().getPollById(req.params.id); 22 | 23 | res.send(JSON.stringify(result)); 24 | }) 25 | ); 26 | 27 | app.post( 28 | "/api/polls", 29 | asyncMiddleware(async (req, res) => { 30 | const result = await new PollsRepository().createPoll(req.body); 31 | 32 | res.send(JSON.stringify(result)); 33 | }) 34 | ); 35 | 36 | app.put( 37 | "/api/polls/:pollId/close", 38 | asyncMiddleware(async (req, res) => { 39 | const result = await new PollsRepository().closePoll(req.params.pollId); 40 | 41 | res.send(JSON.stringify(result)); 42 | }) 43 | ); 44 | 45 | app.put( 46 | "/api/polls/:pollId/open", 47 | asyncMiddleware(async (req, res) => { 48 | const result = await new PollsRepository().openPoll(req.params.pollId); 49 | 50 | res.send(JSON.stringify(result)); 51 | }) 52 | ); 53 | 54 | app.put( 55 | "/api/polls/:pollId/options/:optionId", 56 | asyncMiddleware(async (req, res) => { 57 | const optionId = req.params.optionId; 58 | const pollId = req.params.pollId; 59 | 60 | await new PollsRepository().updateOptionCount(optionId); 61 | 62 | res.cookie(pollId.toString(), optionId.toString(), { 63 | maxAge: 99999999999 64 | }); 65 | 66 | res.send(JSON.stringify(true)); 67 | }) 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/routes/views.js: -------------------------------------------------------------------------------- 1 | const PollsRepository = require("../repositories/pollsRepository"); 2 | const { groupPolls } = require("../utils/polls"); 3 | 4 | module.exports = function(app) { 5 | /** 6 | * Render the default 'show all active' page 7 | * that most users would see. 8 | */ 9 | app.get("/", async (req, res) => { 10 | const polls = groupPolls( 11 | await new PollsRepository().getActivePolls(), 12 | req.cookies 13 | ); 14 | 15 | res.render("index.njk", { 16 | title: "Active Polls", 17 | subtitle: "Polls in progress.", 18 | isAdmin: req.cookies.auth, 19 | polls: polls 20 | }); 21 | }); 22 | 23 | /** 24 | * Render a single poll. This is mostly used 25 | * for sharing a poll with people. 26 | */ 27 | app.get("/polls/:pollId", async (req, res) => { 28 | const polls = groupPolls( 29 | [await new PollsRepository().getPollById(req.params.pollId)], 30 | req.cookies 31 | ); 32 | 33 | res.render("index.njk", { 34 | isAdmin: req.cookies.auth, 35 | shouldHideFooter: true, 36 | shouldHideHero: true, 37 | polls: polls 38 | }); 39 | }); 40 | 41 | /** 42 | * Render the default admin page 43 | * [Currently Empty] 44 | */ 45 | app.get("/admin", function(req, res) { 46 | res.render("admin.njk", { 47 | title: "Admin", 48 | subtitle: "Administrate MOB.", 49 | isAdmin: req.cookies.auth 50 | }); 51 | }); 52 | 53 | /** 54 | * Render the create polls page in the admin 55 | * view. 56 | */ 57 | app.get("/admin/polls/create", function(req, res) { 58 | res.render("admin-create.njk", { 59 | title: "Create Poll", 60 | subtitle: "Create a new poll.", 61 | isAdmin: req.cookies.auth 62 | }); 63 | }); 64 | 65 | /** 66 | * Render the currently active polls. Allows 67 | * for closing and other actions on a poll. 68 | */ 69 | app.get("/admin/polls/active", async (req, res) => { 70 | const polls = groupPolls( 71 | await new PollsRepository().getActivePolls(), 72 | req.cookies 73 | ); 74 | 75 | res.render("admin-active.njk", { 76 | title: "View Active Polls", 77 | subtitle: "Administrate polls currently running.", 78 | isAdmin: req.cookies.auth, 79 | polls: polls 80 | }); 81 | }); 82 | 83 | /** 84 | * Renders the closes polls. 85 | */ 86 | app.get("/admin/polls/closed", async (req, res) => { 87 | const polls = groupPolls( 88 | await new PollsRepository().getClosedPolls(), 89 | req.cookies 90 | ); 91 | 92 | res.render("admin-closed.njk", { 93 | title: "View Closed Polls", 94 | subtitle: "View polls which have been closed.", 95 | isAdmin: req.cookies.auth, 96 | polls: polls 97 | }); 98 | }); 99 | 100 | /** 101 | * Render the login screen to access admin functionality. 102 | */ 103 | app.get("/login", function(req, res) { 104 | res.render("login.njk", { isAdmin: req.cookies.auth }); 105 | }); 106 | }; 107 | -------------------------------------------------------------------------------- /src/utils/db.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | runAsync: function(db, query, params = []) { 3 | return new Promise((resolve, reject) => { 4 | db.run(query, params, function(result, err) { 5 | if (err) { 6 | return reject(err); 7 | } 8 | 9 | return resolve(result); 10 | }); 11 | }); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/hash.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | 3 | module.exports = { 4 | getGuid: () => crypto.randomBytes(16).toString("hex") 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/path.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | BASE_URL: path.resolve(__dirname, "..", "..") 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/polls.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | groupPolls: function(polls, cookies) { 3 | if (!polls) { 4 | return []; 5 | } 6 | 7 | return polls.map((p = {}) => { 8 | return { 9 | ...p, 10 | totalVotes: p.options 11 | ? p.options.reduce((accu, current) => accu + current.totalVotes, 0) 12 | : 0, 13 | votedOption: cookies ? cookies[p.id] : null 14 | }; 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /views/admin-active.njk: -------------------------------------------------------------------------------- 1 | {% extends "admin.njk" %} 2 | 3 | {% block tabs %} 4 | {{ tab.adminTab(1) }} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |