selfhostable, read-only reddit client

add dashboard view, invites

Changed files
+195 -60
src
+34 -1
src/auth.js
··· 1 1 const jwt = require("jsonwebtoken"); 2 + const { db } = require("./db"); 2 3 const { JWT_KEY } = require("./"); 3 4 4 5 function authenticateToken(req, res, next) { ··· 24 25 } 25 26 } 26 27 27 - module.exports = { authenticateToken }; 28 + function authenticateAdmin(req, res, next) { 29 + if (!req.cookies || !req.cookies.auth_token) { 30 + return res.redirect("/login"); 31 + } 32 + 33 + const token = req.cookies.auth_token; 34 + 35 + // If no token, deny access 36 + if (!token) { 37 + return res.redirect( 38 + `/login?redirect=${encodeURIComponent(req.originalUrl)}`, 39 + ); 40 + } 41 + 42 + try { 43 + const user = jwt.verify(token, JWT_KEY); 44 + req.user = user; 45 + const isAdmin = db 46 + .query("SELECT isAdmin FROM users WHERE id = $id and isAdmin = 1") 47 + .get({ 48 + id: req.user.id, 49 + }); 50 + if (isAdmin) { 51 + next(); 52 + } else { 53 + res.status(400).send("only admins can invite"); 54 + } 55 + } catch (error) { 56 + res.send(`failed to authenticate as admin: ${error}`); 57 + } 58 + } 59 + 60 + module.exports = { authenticateToken, authenticateAdmin };
+35
src/db.js
··· 3 3 strict: true, 4 4 }); 5 5 6 + function runMigration(name, migrationFn) { 7 + const exists = db 8 + .query("SELECT * FROM migrations WHERE name = $name") 9 + .get({ name }); 10 + 11 + if (!exists) { 12 + migrationFn(); 13 + db.query("INSERT INTO migrations (name) VALUES ($name)").run({ name }); 14 + } 15 + } 16 + 17 + // users table 6 18 db.query(` 7 19 CREATE TABLE IF NOT EXISTS users ( 8 20 id INTEGER PRIMARY KEY AUTOINCREMENT, ··· 11 23 ) 12 24 `).run(); 13 25 26 + // subs table 14 27 db.query(` 15 28 CREATE TABLE IF NOT EXISTS subscriptions ( 16 29 id INTEGER PRIMARY KEY AUTOINCREMENT, ··· 20 33 UNIQUE(user_id, subreddit) 21 34 ) 22 35 `).run(); 36 + 37 + // migrations table 38 + db.query(` 39 + CREATE TABLE IF NOT EXISTS migrations ( 40 + id INTEGER PRIMARY KEY AUTOINCREMENT, 41 + name TEXT UNIQUE 42 + ) 43 + `).run(); 44 + 45 + runMigration("add-isAdmin-column", () => { 46 + db.query(` 47 + ALTER TABLE users 48 + ADD COLUMN isAdmin INTEGER DEFAULT 0 49 + `).run(); 50 + 51 + // first user is admin 52 + db.query(` 53 + UPDATE users 54 + SET isAdmin = 1 55 + WHERE id = (SELECT MIN(id) FROM users) 56 + `).run(); 57 + }); 23 58 24 59 module.exports = { db };
+1 -1
src/mixins/head.pug
··· 2 2 head 3 3 meta(name="viewport" content="width=device-width, initial-scale=1.0") 4 4 meta(charset='UTF-8') 5 - title #{`readit ${title}`} 5 + title #{`${title} · readit `} 6 6 link(rel="stylesheet", href="/styles.css") 7 7 link(rel="preconnect" href="https://rsms.me/") 8 8 link(rel="stylesheet" href="https://rsms.me/inter/inter.css")
+1 -1
src/mixins/header.pug
··· 10 10 a(href=`/subs`) subscriptions 11 11 if user 12 12 div.header-item 13 - | #{user.username} 13 + a(href='/dashboard') #{user.username} 14 14 |  15 15 a(href='/logout') (logout) 16 16 else
-32
src/mixins/sub.pug
··· 1 - mixin subMgmt() 2 - script. 3 - function getSubs() { 4 - var store = localStorage.getItem('subs'); 5 - if (store) { 6 - return store.split(',').map((n)=>n.replace(/\/?r\//,'')); 7 - } else { 8 - return []; 9 - } 10 - } 11 - 12 - function subscribe(newsub) { 13 - var subs = getSubs(); 14 - if (!subs.includes(newsub)) { 15 - localStorage.setItem('subs',[...subs,newsub]); 16 - updateButton(newsub); 17 - } 18 - } 19 - 20 - function unsubscribe(sub) { 21 - var subs = getSubs(); 22 - if (subs.includes(sub)) { 23 - localStorage.setItem('subs',subs.filter((s)=>s!=sub)); 24 - updateButton(sub); 25 - } 26 - } 27 - 28 - function issub(sub) { 29 - return getSubs().includes(sub); 30 - } 31 - 32 -
+21 -1
src/public/styles.css
··· 517 517 color: var(--text-color-muted); 518 518 } 519 519 520 - .register-error-message { 520 + .register-error-message, 521 + .dashboard-error-message { 521 522 margin-bottom: 1rem; 522 523 flex-flow: row wrap; 523 524 color: var(--error-text-color); 524 525 } 526 + 527 + .invite-table { 528 + width: 100%; 529 + padding: 10px 0; 530 + } 531 + 532 + .invite-table th, 533 + .invite-table td 534 + { 535 + padding: 5px 0; 536 + } 537 + 538 + .invite-table-header { 539 + text-align: left; 540 + } 541 + 542 + .invite-link { 543 + font-family: monospace; 544 + }
+59 -1
src/routes/index.js
··· 5 5 const geddit = require("../geddit.js"); 6 6 const { JWT_KEY } = require("../"); 7 7 const { db } = require("../db"); 8 - const { authenticateToken } = require("../auth"); 8 + const { authenticateToken, authenticateAdmin } = require("../auth"); 9 9 const { validateInviteToken } = require("../invite"); 10 10 11 11 const router = express.Router(); ··· 101 101 .query("SELECT * FROM subscriptions WHERE user_id = $id") 102 102 .all({ id: req.user.id }); 103 103 res.render("subs", { subs, user: req.user }); 104 + }); 105 + 106 + // GET /dashboard 107 + router.get("/dashboard", authenticateToken, async (req, res) => { 108 + let invites = null; 109 + const isAdmin = db 110 + .query("SELECT isAdmin FROM users WHERE id = $id and isAdmin = 1") 111 + .get({ 112 + id: req.user.id, 113 + }); 114 + if (isAdmin) { 115 + invites = db 116 + .query("SELECT * FROM invites") 117 + .all() 118 + .map((inv) => ({ 119 + ...inv, 120 + createdAt: Date.parse(inv.createdAt), 121 + usedAt: Date.parse(inv.usedAt), 122 + })); 123 + } 124 + res.render("dashboard", { invites, isAdmin, user: req.user }); 125 + }); 126 + 127 + router.get("/create-invite", authenticateAdmin, async (req, res) => { 128 + function generateInviteToken() { 129 + const hasher = new Bun.CryptoHasher("sha256", "super-secret-invite-key"); 130 + return hasher.update(Math.random().toString()).digest("hex").slice(0, 10); 131 + } 132 + 133 + function createInvite() { 134 + const token = generateInviteToken(); 135 + db.run("INSERT INTO invites (token) VALUES ($token)", { token }); 136 + } 137 + 138 + try { 139 + db.run(` 140 + CREATE TABLE IF NOT EXISTS invites ( 141 + id INTEGER PRIMARY KEY AUTOINCREMENT, 142 + token TEXT NOT NULL, 143 + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 144 + usedAt TIMESTAMP 145 + ) 146 + `); 147 + 148 + createInvite(); 149 + return res.redirect("/dashboard"); 150 + } catch (err) { 151 + return res.send("failed to create invite"); 152 + } 153 + }); 154 + 155 + router.get("/delete-invite/:id", authenticateToken, async (req, res) => { 156 + try { 157 + db.run("DELETE FROM invites WHERE id = $id", { id: req.params.id }); 158 + return res.redirect("/dashboard"); 159 + } catch (err) { 160 + return res.send("failed to delete invite"); 161 + } 104 162 }); 105 163 106 164 // GET /media
+44
src/views/dashboard.pug
··· 1 + include ../mixins/header 2 + include ../mixins/head 3 + include ../utils 4 + 5 + doctype html 6 + html 7 + +head("dashboard") 8 + body 9 + main#content 10 + +header(user) 11 + div.hero 12 + h1 dashboard 13 + 14 + if message 15 + div.dashboard-error-message 16 + | #{message} 17 + 18 + if isAdmin 19 + h2 invites 20 + 21 + if invites 22 + table.invite-table 23 + tr 24 + th.invite-table-header link 25 + th.invite-table-header created 26 + th.invite-table-header claimed 27 + th.invite-table-header delete 28 + each invite in invites 29 + tr 30 + td.invite-link 31 + a(href=`/register?token=${invite.token}`) #{invite.token} 32 + td #{timeDifference(Date.now(), invite.createdAt)} ago 33 + if invite.usedAt 34 + td #{timeDifference(Date.now(), invite.usedAt)} ago 35 + else 36 + td unclaimed 37 + td 38 + a(href=`/delete-invite/${invite.id}`) delete 39 + 40 + a(href="/create-invite") create invite 41 + 42 + else 43 + p you aren't an admin and therefore there is nothing to see here yet 44 +
-3
src/views/index.pug
··· 1 1 include ../mixins/post 2 - include ../mixins/sub 3 2 include ../mixins/header 4 3 include ../mixins/head 5 4 include ../utils 6 - - var subs = [] 7 5 doctype html 8 6 html 9 7 +head("home") 10 - +subMgmt() 11 8 script(defer). 12 9 async function subscribe(sub) { 13 10 await doThing(sub, 'subscribe');
-20
src/views/subs.pug
··· 1 - include ../mixins/sub 2 1 include ../mixins/header 3 2 include ../mixins/head 4 3 5 4 doctype html 6 5 html 7 6 +head("subscriptions") 8 - +subMgmt() 9 - script. 10 - function newSubItem(sub) { 11 - const p = document.createElement("p"); 12 - const a = document.createElement("a"); 13 - a.href = `/r/${sub}`; 14 - a.innerText = `r/${sub}`; 15 - p.appendChild(a); 16 - return p; 17 - } 18 - 19 - function buildSubList() { 20 - var subList = document.getElementById('subList'); 21 - getSubs().forEach((sub)=>{ 22 - subList.appendChild(newSubItem(sub)); 23 - }); 24 - } 25 - 26 - document.addEventListener('DOMContentLoaded', buildSubList); 27 7 body 28 8 main#content 29 9 +header(user)