From 9d80a009ca455d520ce358ade73fa2ebcfb30d2d Mon Sep 17 00:00:00 2001 From: Wolvan Date: Wed, 29 Dec 2021 01:02:18 +0100 Subject: [PATCH] Create rudimentary poll backend This is currently untested, but the backend allows creating a new poll, getting results of an existing poll, voting and retrieving info of a poll for display purposes. Recaptcha is not yet implemented at this stage. --- .gitignore | 4 +- package-lock.json | 76 ++++++++++++++++++++++- package.json | 6 +- src/backend.ts | 153 +++++++++++++++++++++++++++++++++++++++++++++- src/main.ts | 3 + 5 files changed, 238 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 00be38d..316a8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -110,4 +110,6 @@ testing/ *.sqlite *.sqlite3 *.db -*.db3 \ No newline at end of file +*.db3 +# Data directory +data/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1521edc..384041e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,20 @@ "dependencies": { "commander": "^8.3.0", "compression": "^1.7.4", - "express": "^4.17.2" + "cookie-parser": "^1.4.6", + "express": "^4.17.2", + "node-persist": "^3.1.0" }, "devDependencies": { "@types/chai": "^4.2.22", "@types/chai-as-promised": "^7.1.4", "@types/compression": "^1.7.2", + "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.13", "@types/fs-extra": "^9.0.13", "@types/mocha": "^9.0.0", "@types/node": "^16.11.10", + "@types/node-persist": "^3.1.2", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", "chai": "^4.3.4", @@ -199,6 +203,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -255,6 +268,15 @@ "integrity": "sha512-3aRnHa1KlOEEhJ6+CvyHKK5vE9BcLGjtUpwvqYLRvYNQKMfabu3BwfJaA/SLW8dxe28LsNDjtHwePTuzn3gmOA==", "dev": true }, + "node_modules/@types/node-persist": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@types/node-persist/-/node-persist-3.1.2.tgz", + "integrity": "sha512-aLFUB1951wOfR+tZ4f3TudLPblo9+PfnduMh6feuOTijD0Q6YkMdqPXSgQIjI23FGmCuoZaIZ9x6cvEG/TdiSg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -926,6 +948,18 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -2164,6 +2198,14 @@ "node": ">= 0.6" } }, + "node_modules/node-persist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-3.1.0.tgz", + "integrity": "sha512-/j+fd/u71wNgKf3V2bx4tnDm+3GvLnlCuvf2MXbJ3wern+67IAb6zN9Leu1tCWPlPNZ+v1hLSibVukkPK2HqJw==", + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3201,6 +3243,15 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -3257,6 +3308,15 @@ "integrity": "sha512-3aRnHa1KlOEEhJ6+CvyHKK5vE9BcLGjtUpwvqYLRvYNQKMfabu3BwfJaA/SLW8dxe28LsNDjtHwePTuzn3gmOA==", "dev": true }, + "@types/node-persist": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@types/node-persist/-/node-persist-3.1.2.tgz", + "integrity": "sha512-aLFUB1951wOfR+tZ4f3TudLPblo9+PfnduMh6feuOTijD0Q6YkMdqPXSgQIjI23FGmCuoZaIZ9x6cvEG/TdiSg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3748,6 +3808,15 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -4680,6 +4749,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "node-persist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-3.1.0.tgz", + "integrity": "sha512-/j+fd/u71wNgKf3V2bx4tnDm+3GvLnlCuvf2MXbJ3wern+67IAb6zN9Leu1tCWPlPNZ+v1hLSibVukkPK2HqJw==" + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 2e59a57..f858101 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,12 @@ "@types/chai": "^4.2.22", "@types/chai-as-promised": "^7.1.4", "@types/compression": "^1.7.2", + "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.13", "@types/fs-extra": "^9.0.13", "@types/mocha": "^9.0.0", "@types/node": "^16.11.10", + "@types/node-persist": "^3.1.2", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", "chai": "^4.3.4", @@ -56,6 +58,8 @@ "dependencies": { "commander": "^8.3.0", "compression": "^1.7.4", - "express": "^4.17.2" + "cookie-parser": "^1.4.6", + "express": "^4.17.2", + "node-persist": "^3.1.0" } } diff --git a/src/backend.ts b/src/backend.ts index b1de6a6..9e23103 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -1,7 +1,158 @@ "use strict"; import { Router } from "express"; +import persist from "node-persist"; +import { program } from "commander"; -export default function init(router: Router): void { +type Poll = { + id: string, + title: string, + options: { + [option: string]: number + }, + dupeCheckMode: "none" | "ip" | "cookie", + dupeData: null | string[] | string, + multiSelect: boolean, + captcha: boolean, + creationTime: Date, +}; +function randomString(length = 10, charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") { + let result = ""; + for (let i = 0; i < length; i++) { + result += charset.charAt(Math.floor(Math.random() * charset.length)); + } + return result; +} + +export default async function init(router: Router): Promise { + const polls = await persist.create({ + dir: program.opts().dataDirectory + }); + await polls.init(); + + router.get("/poll/:id", async (req, res) => { + try { + const id = req.params.id; + const poll: (Poll | undefined) = await polls.getItem(id); + if (!poll) return res.status(404).json({ error: "Poll not found" }); + res.json(Object.assign({}, poll, { + options: Object.keys(poll.options), + dupeData: null + })); + } catch (error) { + console.error(error); + if (error instanceof Error) res.status(500).json({ + error: error.message + }); + else res.status(500).json({ + error: error + }); + } + }); + + router.get("/poll-result/:id", async (req, res) => { + try { + const id = req.params.id; + const poll: (Poll | undefined) = await polls.getItem(id); + if (!poll) return res.status(404).json({ error: "Poll not found" }); + res.json({ + title: poll.title, + votes: poll.options + }); + } catch (error) { + console.error(error); + if (error instanceof Error) res.status(500).json({ + error: error.message + }); + else res.status(500).json({ + error: error + }); + } + }); + + router.post("/poll", async (req, res) => { + try { + const options = req.body.options; + if (!Array.isArray(options) || options.filter(i => i).length < 2) + return res.status(400).json({ error: "Options must be an array and have at least 2 entries" }); + let id = randomString(6); + while (await polls.getItem(id)) id = randomString(6); + await polls.setItem(id, {}); + const dupeCheckMode = ["none", "ip", "cookie"].includes(req.body.dupeCheckMode) ? req.body.dupeCheckMode : "ip"; + const dupeData = + dupeCheckMode === "none" ? null : + dupeCheckMode === "ip" ? [] : + dupeCheckMode === "cookie" ? randomString(16) : null; + const poll: Poll = { + id, + title: req.body.title || "", + options: (() => { + const result: { [option: string]: number } = {}; + for (const option of options) { + result[option] = 0; + } + return result; + })(), + dupeCheckMode, + dupeData, + multiSelect: req.body.multiSelect || false, + captcha: req.body.captcha || false, + creationTime: new Date() + }; + await polls.setItem(id, poll); + res.json({ + id: id + }); + } catch (error) { + console.error(error); + if (error instanceof Error) res.status(500).json({ + error: error.message + }); + else res.status(500).json({ + error: error + }); + } + }); + + router.post("/vote/:id", async (req, res) => { + try { + const id = req.params.id; + const poll: (Poll | undefined) = await polls.getItem(id); + if (!poll) return res.status(404).json({ error: "Poll not found" }); + + const votes = req.body.votes; + const possibleVotes = Object.keys(poll.options); + if (!Array.isArray(votes) || votes.filter(i => i && possibleVotes.includes(i)).length < 1) + return res.status(400).json({ error: "Votes must be an array and have at least 1 entry" }); + if (!poll.multiSelect && votes.filter(i => i && possibleVotes.includes(i)).length > 1) + return res.status(400).json({ error: "Single-select polls can only have one vote" }); + + if (poll.dupeCheckMode === "ip") { + const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress || ""; + if (Array.isArray(poll.dupeData) && poll.dupeData.includes(ip as string)) return res.status(200).json({ status: "ok", id }); + if (Array.isArray(poll.dupeData)) poll.dupeData.push(ip as string); + } else if (poll.dupeCheckMode === "cookie") { + const cookie = req.cookies[poll.dupeData as string]; + if (cookie) return res.status(200).json({ status: "ok", id }); + res.cookie(poll.dupeData as string, "1", { + httpOnly: true, + maxAge: (1000 * 60 * 60 * 24 * 365) / 2 + }); + } + + votes.filter(i => i && possibleVotes.includes(i)).forEach(vote => poll.options[vote]++); + await polls.setItem(id, poll); + + res.json({ status: "ok", id }); + } catch (error) { + console.error(error); + if (error instanceof Error) res.status(500).json({ + error: error.message + }); + else res.status(500).json({ + error: error + }); + } + }); } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index bc4f5b0..69c419c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,14 @@ import loadConfig from "./config-loader"; import { program } from "commander"; import express from "express"; import compression from "compression"; +import cookiepaser from "cookie-parser"; import { resolve } from "path"; async function main(): Promise { await loadConfig([ ["--no-frontend", "Do not start the frontend server"], ["--no-backend", "Do not start the backend server"], + ["-d, --data-directory ", "Path to the data directory", "../data"], ["-p, --port ", "Port to listen on", (port: any) => parseInt(port), 6969], ["--backend-base-url ", "Base URL for the backend server", null], ], ".poll-horse-config"); @@ -18,6 +20,7 @@ async function main(): Promise { const app = express(); app.use(express.json()); app.use(compression()); + app.use(cookiepaser()); if (opts.backend) { console.log(`Mounting backend`);