From 9fa3eabad1d935a1461953857f8bb38a2810d10a Mon Sep 17 00:00:00 2001 From: Wolvan Date: Sat, 1 Jan 2022 04:13:24 +0100 Subject: [PATCH] Make frontend js-less The frontend should work without having JS enabled. JS will enhance the experience but should under no circumstance be necessary. To achieve this, the entire entry system has been turned into a form that posts its values to a new backend path specifically made to take form responses. Instead of returning an API response, it also redirects the browser to either the voting page on successful creation or the frontpage with a bunch of get parameters that are used to prefill the form in a server-side rendering process. An error parameter is also given but there is no way to display said error for now. --- frontend/html/index.html | 48 +++++++-------- frontend/static/css/main.css | 2 + src/Poll.ts | 7 ++- src/backend.ts | 113 ++++++++++++++++++++++++++--------- src/frontend.ts | 26 +++++++- src/main.ts | 1 + 6 files changed, 138 insertions(+), 59 deletions(-) diff --git a/frontend/html/index.html b/frontend/html/index.html index a8463c5..a4958c3 100644 --- a/frontend/html/index.html +++ b/frontend/html/index.html @@ -17,33 +17,27 @@
-
- -
-
-
- -
-
- -
-
- -
-
-
diff --git a/frontend/static/css/main.css b/frontend/static/css/main.css index d8baba9..953470e 100644 --- a/frontend/static/css/main.css +++ b/frontend/static/css/main.css @@ -88,6 +88,7 @@ main .notepad-border { } /* #endregion notepad */ /* #region notepad-footer */ +main .notepad .poll-footer input[type="submit"], main .notepad .poll-footer button { padding: 0 30px; height: 50px; @@ -100,6 +101,7 @@ main .notepad .poll-footer button { color: #fff; margin-right: 5px; } +main .notepad .poll-footer input[type="submit"]:hover, main .notepad .poll-footer button:hover { background-color: #b6b6b6; } diff --git a/src/Poll.ts b/src/Poll.ts index 33ab9bb..9097eb1 100644 --- a/src/Poll.ts +++ b/src/Poll.ts @@ -1,9 +1,9 @@ "use strict"; - +type DupeCheckMode = "none" | "ip" | "cookie"; type BasePoll = { id: string, title: string, - dupeCheckMode: "none" | "ip" | "cookie", + dupeCheckMode: DupeCheckMode, multiSelect: boolean, captcha: boolean, creationTime: Date, @@ -30,5 +30,6 @@ type PollResult = { export { FrontendPoll, BackendPoll, - PollResult + PollResult, + DupeCheckMode }; diff --git a/src/backend.ts b/src/backend.ts index 182774e..3d03f44 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -4,7 +4,7 @@ import { Router } from "express"; import persist from "node-persist"; import { program } from "commander"; import { resolve } from "path"; -import { BackendPoll as Poll } from "./Poll"; +import { BackendPoll as Poll, DupeCheckMode } from "./Poll"; function randomString(length = 10, charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789") { let result = ""; @@ -60,39 +60,96 @@ export default async function init(router: Router): Promise { } }); + async function createPoll(pollData: { + title: string, + options: string[], + dupeCheckMode: DupeCheckMode, + multiSelect: boolean, + captcha: boolean + }): Promise { + if (!Array.isArray(pollData.options) || pollData.options.filter(i => i).length < 2) + return "Options must be an array and have at least 2 entries"; + + let id = randomString(8); + while (await polls.getItem(id)) id = randomString(6); + await polls.setItem(id, {}); + + const dupeCheckMode = ( + ["none", "ip", "cookie"].includes((pollData.dupeCheckMode || "").toLowerCase()) ? + (pollData.dupeCheckMode || "").toLowerCase() : "ip" + ) as DupeCheckMode; + const dupeData = + dupeCheckMode === "none" ? null : + dupeCheckMode === "ip" ? [] : + dupeCheckMode === "cookie" ? randomString(16) : null; + const poll: Poll = { + id, + title: (pollData.title || "").trim().slice(0, 300), + options: (() => { + const result: { [option: string]: number } = {}; + for (const option of pollData.options.map(i => i.trim().slice(0, 300))) { + if (option) result[option] = 0; + } + return result; + })(), + dupeCheckMode, + dupeData, + multiSelect: pollData.multiSelect || false, + captcha: pollData.captcha || false, + creationTime: new Date() + }; + await polls.setItem(id, poll); + return poll; + } + 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(8); - while (await polls.getItem(id)) id = randomString(6); - await polls.setItem(id, {}); - const dupeCheckMode = ["none", "ip", "cookie"].includes((req.body.dupeCheckMode || "").toLowerCase()) ? (req.body.dupeCheckMode || "").toLowerCase() : "ip"; - const dupeData = - dupeCheckMode === "none" ? null : - dupeCheckMode === "ip" ? [] : - dupeCheckMode === "cookie" ? randomString(16) : null; - const poll: Poll = { - id, + const poll = await createPoll({ title: (req.body.title || "").trim().slice(0, 300), - options: (() => { - const result: { [option: string]: number } = {}; - for (const option of options.map(i => i.trim().slice(0, 300))) { - if (option) result[option] = 0; - } - return result; - })(), - dupeCheckMode, - dupeData, + options: req.body.options, + dupeCheckMode: req.body.dupeCheckMode, multiSelect: req.body.multiSelect || false, captcha: req.body.captcha || false, - creationTime: new Date() - }; - await polls.setItem(id, poll); - res.json({ - id: id }); + if (typeof poll !== "string") res.json({ + id: poll.id + }); + else res.status(400).json({ + error: poll + }); + } 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-form", async (req, res) => { + try { + const poll = await createPoll({ + title: (req.body["poll-title"] || "").trim().slice(0, 300), + options: req.body["poll-option"], + dupeCheckMode: req.body["dupe-check"], + multiSelect: req.body["multi-select"] === "on", + captcha: req.body["captcha"] === "on", + }); + if (typeof poll !== "string") res.redirect("/" + poll.id); + else res.redirect(`/?error=${ + encodeURIComponent(poll) + }&title=${ + encodeURIComponent(req.body["poll-title"]) + }&options=${ + encodeURIComponent((req.body["poll-option"] || []).join("\uFFFE")) + }&dupecheck=${ + encodeURIComponent(req.body["dupe-check"]) + }&multiselect=${ + req.body["multi-select"] === "on" + }&captcha=${ + req.body["captcha"] === "on" + }`); } catch (error) { console.error(error); if (error instanceof Error) res.status(500).json({ diff --git a/src/frontend.ts b/src/frontend.ts index 71575f7..b6865e4 100644 --- a/src/frontend.ts +++ b/src/frontend.ts @@ -126,5 +126,29 @@ export default function init(router: Router): void { res.redirect(`/`); } }); - router.get("/", (req, res) => displayPage(req, res, "index.html")); + + router.get("/", (req, res) => { + const options = (typeof req.query.options === "string" ? req.query.options.split("\uFFFE") : []) + .filter(i => i) + .concat(Array(3).fill("")) + .slice(0, 3); + const pollOptionDivs = options.map(option => ` +
+ +
+ `).join(""); + + displayPage(req, res, "index.html", { + "BACKEND_BASE_PATH": (program.opts().backendBaseUrl || ""), + "FORM_SUBMISSION_ERROR": req.query.error, + "FORM_SUBMISSION_ERROR_SHOWN_CLASS": req.query.error ? "error-visible" : "", + "FORM_TITLE": req.query.title || "", + "FORM_DUPECHECK_IP": req.query.dupecheck === "ip" ? "selected" : "", + "FORM_DUPECHECK_COOKIE": req.query.dupecheck === "cookie" ? "selected" : "", + "FORM_DUPECHECK_NONE": req.query.dupecheck === "none" ? "selected" : "", + "FORM_MULTI_SELECT": req.query.multiselect === "true" ? "checked" : "", + "FORM_CAPTCHA": req.query.captcha === "true" ? "checked" : "", + "FORM_OPTION_DIVS": pollOptionDivs + }); + }); } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 0bf0811..05ebbb3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ async function main(): Promise { const app = express(); app.use(express.json()); + app.use(express.urlencoded({ extended: true })); app.use(compression()); app.use(cookiepaser());