diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7b0b862 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/index.js", + "env": { + "PORT": "8888" + } + } + ] +} \ No newline at end of file diff --git a/index.js b/index.js index bf600ec..7de6ba4 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -global._CC = {} +global._CC = { require } const expressSessionLevel = require('express-session-level'); const LocalStrategy = require('passport-local').Strategy; const session = require('express-session'); @@ -42,7 +42,7 @@ passport.serializeUser((user, callback) => callback(null, user._id)); passport.deserializeUser((user, callback) => { db.get(user) .then(dbUser => callback(null, dbUser)) - .catch(err => callback(err)); + .catch(() => callback(null, null)); }); diff --git a/middlewares/verifyAuth.js b/middlewares/verifyAuth.js index 1d43679..1dab01e 100644 --- a/middlewares/verifyAuth.js +++ b/middlewares/verifyAuth.js @@ -2,10 +2,13 @@ const config = require('../config'); module.exports = options => { return (req, res, next) => { options = options ? options : {}; - if (req.isAuthenticated()) { - next(); - } else { - res.redirect(options.failureRedirect || config.defaultFailureRedirect); + let authed = false + try { + authed = req.isAuthenticated() + } catch { + return res.send('auth fail') } + if (authed) return next() + res.redirect(options.failureRedirect || config.defaultFailureRedirect) }; } \ No newline at end of file diff --git a/package.json b/package.json index 8171f0a..53ac47f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "express-session-level": "^1.0.0", "get-product-name": "^1.5.0", "level": "^6.0.0", + "moment": "^2.29.1", + "nanoid": "^3.1.16", "passport": "^0.4.0", "passport-local": "^1.0.0", "pouchdb": "^7.0.0", diff --git a/routes/adminSettings/index.js b/routes/adminSettings/index.js index 48a4884..dc9aee5 100644 --- a/routes/adminSettings/index.js +++ b/routes/adminSettings/index.js @@ -1,6 +1,16 @@ const verifyAuth = require('../../middlewares/verifyAuth'); const bcrypt = require('bcrypt-nodejs'); const express = require('express'); +const { nanoid } = require('nanoid') + +const SIGNUP_TOKEN_LENGTH = 32 +const SIGNUP_TOKEN_LIFETIME = + // One week, approximately. Doesn't need to be perfect. + 1000 // milliseconds + * 60 // seconds + * 60 // minutes + * 24 // hours + * 07 // days module.exports = (db) => { const router = express.Router(); @@ -16,25 +26,81 @@ module.exports = (db) => { router.post('/add', verifyAuth(), async (req, res) => { if (!req.user.admin) return res.redirect('/'); - bcrypt.hash(req.body.newUserPassword, null, null, async (err, newUserPasswordHash) => { - if (err) throw err; - await db.put({ - _id: req.body.newUserUsername.trim(), - password: newUserPasswordHash, - admin: false, - wishlist: [] - }); - req.flash('success', `Successfully added user ${req.body.newUserUsername.trim()}!`); - res.redirect('/admin-settings'); + await db.put({ + _id: req.body.newUserUsername.trim(), + admin: false, + wishlist: [], + + signupToken: nanoid(SIGNUP_TOKEN_LENGTH), + expiry: new Date().getTime() + SIGNUP_TOKEN_LIFETIME + }); + res.redirect(`/admin-settings/edit/${req.body.newUserUsername.trim()}`) }); - router.get('/remove/:userToRemove', verifyAuth(), (req, res) => { + router.get('/edit/:userToEdit', verifyAuth(), async (req, res) => { if (!req.user.admin) return res.redirect('/'); - res.render('remove', { userToRemove: req.params.userToRemove }); + const doc = await db.get(req.params.userToEdit) + delete doc.password + res.render('admin-user-edit', { user: doc }); }); - router.post('/remove/:userToRemove', verifyAuth(), async (req, res) => { + router.post('/edit/refresh-signup-token/:userToEdit', verifyAuth(), async (req, res) => { + if (!req.user.admin) return res.redirect('/'); + const doc = await db.get(req.params.userToEdit) + doc.signupToken = nanoid(SIGNUP_TOKEN_LENGTH) + doc.expiry = new Date().getTime() + SIGNUP_TOKEN_LIFETIME + await db.put(doc) + return res.redirect(`/admin-settings/edit/${req.params.userToEdit}`) + }); + + router.post('/edit/rename/:userToRename', verifyAuth(), async (req, res) => { + if (!req.user.admin && req.user._id !== req.params.userToRename) return res.redirect('/') + if (!req.body.newUsername) { + req.flash('error', 'No username provided') + return res.redirect(`/admin-settings/edit/${req.params.userToRename}`) + } + if (req.body.newUsername === req.params.userToRename) { + req.flash('error', 'Username is same as new username.') + return res.redirect(`/admin-settings/edit/${req.params.userToRename}`) + } + + const oldName = req.params.userToRename + const newName = req.body.newUsername + + const userDoc = await db.get(oldName) + userDoc._id = newName + delete userDoc._rev + try { + await db.put(userDoc) + try { + const usersBulk = [] + const users = (await db.allDocs({ include_docs: true })).rows + for (const { doc: user } of users) { + for (const item of user.wishlist) { + if (item.pledgedBy === oldName) item.pledgedBy = newName + if (item.addedBy === oldName) item.addedBy = newName + } + usersBulk.push(user) + } + + await db.bulkDocs(usersBulk) + await db.remove(await db.get(oldName)) + + await req.flash('success', 'Renamed user!') + return res.redirect(`/wishlist/${newName}`) + } catch (error) { + console.log(error, error.stack) + await db.remove(await db.get(newName)) + throw error + } + } catch (error) { + req.flash('error', error.message) + return res.redirect(`/admin-settings/edit/${oldName}`) + } + }) + + router.post('/edit/remove/:userToRemove', verifyAuth(), async (req, res) => { if (!req.user.admin) return res.redirect('/'); const doc = await db.get(req.params.userToRemove); if (doc.admin) { diff --git a/routes/confirm-account/index.js b/routes/confirm-account/index.js new file mode 100644 index 0000000..dbe5974 --- /dev/null +++ b/routes/confirm-account/index.js @@ -0,0 +1,44 @@ +const bcrypt = require('bcrypt-nodejs'); +const express = require('express'); + +module.exports = (db) => { + const router = express.Router(); + + router.get('/:code', async (req, res) => { + const { doc } = (await db.allDocs({ include_docs: true })) + .rows + .find(({ doc }) => doc.signupToken === req.params.code) + + res.render('confirm-account', { doc }) + }); + + router.post('/:code', async (req, res) => { + const { doc } = (await db.allDocs({ include_docs: true })) + .rows + .find(({ doc }) => doc.signupToken === req.params.code) + + if (doc.expiry < new Date().getTime()) return res.redirect(`/confirm-account/${req.params.code}`) + + bcrypt.hash(req.body.password, null, null, async (err, passwordHash) => { + if (err) throw err; + + doc.password = passwordHash + delete doc.signupToken + delete doc.expiry + + await db.put(doc) + + req.login({ _id: doc._id }, err => { + if (err) { + console.log(err) + req.flash('error', err.message) + return res.redirect('/') + } + req.flash('success', `Welcome to ${_CC.config.siteTitle}!`); + res.redirect('/'); + }) + }); + }); + + return router; +}; \ No newline at end of file diff --git a/routes/index.js b/routes/index.js index 68f7cbe..40c3dc1 100644 --- a/routes/index.js +++ b/routes/index.js @@ -28,6 +28,7 @@ module.exports = ({ db, config }) => { router.use('/login', require('./login')()); router.use('/logout', require('./logout')()); + router.use('/confirm-account', require('./confirm-account')(db)); router.use('/wishlist', require('./wishlist')(db)); diff --git a/views/admin-user-edit.pug b/views/admin-user-edit.pug new file mode 100644 index 0000000..9646340 --- /dev/null +++ b/views/admin-user-edit.pug @@ -0,0 +1,53 @@ +extends layout.pug + +block title + h1(style="margin-bottom: 0;") + a(href='..') < + | #{config.siteTitle} + p Editing user "#{user._id}" + +block content + .columns + .column + if user.signupToken + - const link = `${_CC.config.base}confirm-account/${user.signupToken}` + .box(style='overflow: hidden;') + .columns(style='margin-bottom: 0;') + .column.is-narrow(style='padding-bottom: 0;') + h2 Confirmation Link + .column(style='padding-bottom: 0;') + p + span This account hasn't been confirmed. + br + if user.expiry > new Date().getTime() + span= `The following link expires ${_CC.require('moment')(user.expiry).fromNow()}` + else + span.has-text-weight-bold(style='color: red;')= `The following link expired ${_CC.require('moment')(user.expiry).fromNow()}` + h3(style='margin-bottom: 0; margin-top: 0;') + .level + .level-left + .level-item + form(method='POST', action=`${_CC.config.base}admin-settings/edit/refresh-signup-token/${user._id}`) + input.button.is-rounded(type='submit', value='Generate New Link') + .level-item + a(href=link, style='font-family: monospaced; word-break: break-all;')= link + h2 Change Name + form(action=`${_CC.config.base}admin-settings/edit/rename/${user._id}`, method='POST') + .field + label.label Username + .control.has-icons-left + input.input(type='text', name='newUsername', placeholder=user._id, value=user._id) + span.icon.is-small.is-left + i.fas.fa-user + .field + .control + input.button.is-primary(type='submit' value='Change Username') + .column.is-narrow + h2 Irreversible Deletion + form(method='POST', action=`${_CC.config.base}admin-settings/edit/remove/${user._id}`) + .field + .control + if user.admin + input.button.is-danger(disabled, type='submit' value=`User is admin`) + else + input.button.is-danger(type='submit' value=`Remove user ${user._id}`) \ No newline at end of file diff --git a/views/adminSettings.pug b/views/adminSettings.pug index 093b8b4..dec1999 100644 --- a/views/adminSettings.pug +++ b/views/adminSettings.pug @@ -4,12 +4,11 @@ block content h2 Users each user in users span.is-size-6.inline= user.id - if !user.doc.admin - a(href=`${_CC.config.base}admin-settings/remove/${user.id}`) - span.is-size-7.icon.has-text-danger - i.fas.fa-times - span.is-sr-only - Remove + a(href=`${_CC.config.base}admin-settings/edit/${user.id}`) + span.is-size-7.icon.has-text-info + i.fas.fa-edit + span.is-sr-only + | Edit br h3 Add user form(action=`${_CC.config.base}admin-settings/add`, method='POST') @@ -19,12 +18,9 @@ block content input.input(type='text', name='newUserUsername', placeholder='john') span.icon.is-small.is-left i.fas.fa-user - .field - label.label Password - .control.has-icons-left - input.input(type='password', name='newUserPassword', placeholder='pa$$word!') - span.icon.is-small.is-left - i.fas.fa-lock .field .control - input.button.is-primary(type='submit' value='Add User') \ No newline at end of file + input.button.is-primary(type='submit' value='Add User') + h3 Version Info + p Christmas Community: v#{_CC.require('./package.json').version} + p Node: #{process.version} \ No newline at end of file diff --git a/views/confirm-account.pug b/views/confirm-account.pug new file mode 100644 index 0000000..bb6e381 --- /dev/null +++ b/views/confirm-account.pug @@ -0,0 +1,34 @@ +extends layout.pug + +mixin icon(c, text) + .columns.is-vcentered.is-mobile + .column.is-narrow + span.icon.is-large + i.fa-3x(class=c) + .column #{text} + +block title + if doc + h1 #{config.siteTitle} | Confirm Account + else + h1 #{config.siteTitle} | Confirmation Link Invalid + +block content + if doc + if doc.expiry > new Date().getTime() + +icon('fas fa-smile-beam', `Hello ${doc._id}! Please set your password here.`) + form(method='POST') + .field + label.label Password + .control.has-icons-left + input.input(type='password', name='password', placeholder='pa$$word!') + span.icon.is-small.is-left + i.fas.fa-lock + .field + .control + input.button.is-primary(type='submit' value=`Join ${_CC.config.siteTitle}`) + else + +icon('fas fa-frown-open', 'Your confirmation link has expired. Please ask for a new one.') + else + +icon('fas fa-frown-open', "This confirmation link isn't valid, perhaps the account was deleted or some characters at the end got cut off?") + diff --git a/views/remove.pug b/views/remove.pug deleted file mode 100644 index 691284e..0000000 --- a/views/remove.pug +++ /dev/null @@ -1,7 +0,0 @@ -extends layout.pug - -block content - form(method='POST') - .field - .control - input.button.is-danger(type='submit' value=`Remove user ${userToRemove}`) \ No newline at end of file