diff options
-rw-r--r-- | src/auth.js | 35 | ||||
-rw-r--r-- | src/db.js | 35 | ||||
-rw-r--r-- | src/mixins/head.pug | 2 | ||||
-rw-r--r-- | src/mixins/header.pug | 2 | ||||
-rw-r--r-- | src/mixins/sub.pug | 32 | ||||
-rw-r--r-- | src/public/styles.css | 22 | ||||
-rw-r--r-- | src/routes/index.js | 60 | ||||
-rw-r--r-- | src/views/dashboard.pug | 44 | ||||
-rw-r--r-- | src/views/index.pug | 3 | ||||
-rw-r--r-- | src/views/subs.pug | 20 |
10 files changed, 195 insertions, 60 deletions
diff --git a/src/auth.js b/src/auth.js index f907e6c..78e3dea 100644 --- a/src/auth.js +++ b/src/auth.js | |||
@@ -1,4 +1,5 @@ | |||
1 | const jwt = require("jsonwebtoken"); | 1 | const jwt = require("jsonwebtoken"); |
2 | const { db } = require("./db"); | ||
2 | const { JWT_KEY } = require("./"); | 3 | const { JWT_KEY } = require("./"); |
3 | 4 | ||
4 | function authenticateToken(req, res, next) { | 5 | function authenticateToken(req, res, next) { |
@@ -24,4 +25,36 @@ 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 }; | ||
@@ -3,6 +3,18 @@ const db = new Database("readit.db", { | |||
3 | strict: true, | 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 | db.query(` | 18 | db.query(` |
7 | CREATE TABLE IF NOT EXISTS users ( | 19 | CREATE TABLE IF NOT EXISTS users ( |
8 | id INTEGER PRIMARY KEY AUTOINCREMENT, | 20 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
@@ -11,6 +23,7 @@ db.query(` | |||
11 | ) | 23 | ) |
12 | `).run(); | 24 | `).run(); |
13 | 25 | ||
26 | // subs table | ||
14 | db.query(` | 27 | db.query(` |
15 | CREATE TABLE IF NOT EXISTS subscriptions ( | 28 | CREATE TABLE IF NOT EXISTS subscriptions ( |
16 | id INTEGER PRIMARY KEY AUTOINCREMENT, | 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
@@ -21,4 +34,26 @@ db.query(` | |||
21 | ) | 34 | ) |
22 | `).run(); | 35 | `).run(); |
23 | 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 | }); | ||
58 | |||
24 | module.exports = { db }; | 59 | module.exports = { db }; |
diff --git a/src/mixins/head.pug b/src/mixins/head.pug index b95f661..f96e91c 100644 --- a/src/mixins/head.pug +++ b/src/mixins/head.pug | |||
@@ -2,7 +2,7 @@ mixin head(title) | |||
2 | head | 2 | head |
3 | meta(name="viewport" content="width=device-width, initial-scale=1.0") | 3 | meta(name="viewport" content="width=device-width, initial-scale=1.0") |
4 | meta(charset='UTF-8') | 4 | meta(charset='UTF-8') |
5 | title #{`readit ${title}`} | 5 | title #{`${title} ยท readit `} |
6 | link(rel="stylesheet", href="/styles.css") | 6 | link(rel="stylesheet", href="/styles.css") |
7 | link(rel="preconnect" href="https://rsms.me/") | 7 | link(rel="preconnect" href="https://rsms.me/") |
8 | link(rel="stylesheet" href="https://rsms.me/inter/inter.css") | 8 | link(rel="stylesheet" href="https://rsms.me/inter/inter.css") |
diff --git a/src/mixins/header.pug b/src/mixins/header.pug index 02a8667..4bec1f8 100644 --- a/src/mixins/header.pug +++ b/src/mixins/header.pug | |||
@@ -10,7 +10,7 @@ mixin header(user) | |||
10 | a(href=`/subs`) subscriptions | 10 | a(href=`/subs`) subscriptions |
11 | if user | 11 | if user |
12 | div.header-item | 12 | div.header-item |
13 | | #{user.username} | 13 | a(href='/dashboard') #{user.username} |
14 | | | 14 | | |
15 | a(href='/logout') (logout) | 15 | a(href='/logout') (logout) |
16 | else | 16 | else |
diff --git a/src/mixins/sub.pug b/src/mixins/sub.pug deleted file mode 100644 index a40aa68..0000000 --- a/src/mixins/sub.pug +++ /dev/null | |||
@@ -1,32 +0,0 @@ | |||
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 | |||
diff --git a/src/public/styles.css b/src/public/styles.css index 6c6e705..ba2940d 100644 --- a/src/public/styles.css +++ b/src/public/styles.css | |||
@@ -517,8 +517,28 @@ form input[type="submit"]:hover { | |||
517 | color: var(--text-color-muted); | 517 | color: var(--text-color-muted); |
518 | } | 518 | } |
519 | 519 | ||
520 | .register-error-message { | 520 | .register-error-message, |
521 | .dashboard-error-message { | ||
521 | margin-bottom: 1rem; | 522 | margin-bottom: 1rem; |
522 | flex-flow: row wrap; | 523 | flex-flow: row wrap; |
523 | color: var(--error-text-color); | 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 | } | ||
diff --git a/src/routes/index.js b/src/routes/index.js index 6efeb79..e7ca573 100644 --- a/src/routes/index.js +++ b/src/routes/index.js | |||
@@ -5,7 +5,7 @@ const jwt = require("jsonwebtoken"); | |||
5 | const geddit = require("../geddit.js"); | 5 | const geddit = require("../geddit.js"); |
6 | const { JWT_KEY } = require("../"); | 6 | const { JWT_KEY } = require("../"); |
7 | const { db } = require("../db"); | 7 | const { db } = require("../db"); |
8 | const { authenticateToken } = require("../auth"); | 8 | const { authenticateToken, authenticateAdmin } = require("../auth"); |
9 | const { validateInviteToken } = require("../invite"); | 9 | const { validateInviteToken } = require("../invite"); |
10 | 10 | ||
11 | const router = express.Router(); | 11 | const router = express.Router(); |
@@ -103,6 +103,64 @@ router.get("/subs", authenticateToken, async (req, res) => { | |||
103 | res.render("subs", { subs, user: req.user }); | 103 | res.render("subs", { subs, user: req.user }); |
104 | }); | 104 | }); |
105 | 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 | } | ||
162 | }); | ||
163 | |||
106 | // GET /media | 164 | // GET /media |
107 | router.get("/media/*", authenticateToken, async (req, res) => { | 165 | router.get("/media/*", authenticateToken, async (req, res) => { |
108 | const url = req.params[0]; | 166 | const url = req.params[0]; |
diff --git a/src/views/dashboard.pug b/src/views/dashboard.pug new file mode 100644 index 0000000..be6c6f3 --- /dev/null +++ b/src/views/dashboard.pug | |||
@@ -0,0 +1,44 @@ | |||
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 | |||
diff --git a/src/views/index.pug b/src/views/index.pug index ace8922..140cd57 100644 --- a/src/views/index.pug +++ b/src/views/index.pug | |||
@@ -1,13 +1,10 @@ | |||
1 | include ../mixins/post | 1 | include ../mixins/post |
2 | include ../mixins/sub | ||
3 | include ../mixins/header | 2 | include ../mixins/header |
4 | include ../mixins/head | 3 | include ../mixins/head |
5 | include ../utils | 4 | include ../utils |
6 | - var subs = [] | ||
7 | doctype html | 5 | doctype html |
8 | html | 6 | html |
9 | +head("home") | 7 | +head("home") |
10 | +subMgmt() | ||
11 | script(defer). | 8 | script(defer). |
12 | async function subscribe(sub) { | 9 | async function subscribe(sub) { |
13 | await doThing(sub, 'subscribe'); | 10 | await doThing(sub, 'subscribe'); |
diff --git a/src/views/subs.pug b/src/views/subs.pug index 86de604..41f29ce 100644 --- a/src/views/subs.pug +++ b/src/views/subs.pug | |||
@@ -1,29 +1,9 @@ | |||
1 | include ../mixins/sub | ||
2 | include ../mixins/header | 1 | include ../mixins/header |
3 | include ../mixins/head | 2 | include ../mixins/head |
4 | 3 | ||
5 | doctype html | 4 | doctype html |
6 | html | 5 | html |
7 | +head("subscriptions") | 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 | body | 7 | body |
28 | main#content | 8 | main#content |
29 | +header(user) | 9 | +header(user) |