Initial Commit

This commit is contained in:
Wingysam 2018-11-20 14:19:58 -05:00
commit f54d97e4a9
30 changed files with 2532 additions and 0 deletions

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directories
node_modules/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# Mac files
.DS_Store
# Database
db/

27
README.md Normal file
View file

@ -0,0 +1,27 @@
# Christmas Community
Web app for your family's Christmas shopping
## Purpose
To create a simple place for your entire family to use to find gifts that people want, and to avoid double-gifting.
## Install
```sh
git clone https://gitlab.com/wingysam/christmas-community
cd christmas-community
yarn
```
## Configuration
Add environment variables with a .env. Example:
```env
SITE_TITLE="Christmas Zone"
PORT=80
```
## Startup
```sh
yarn start
```
## Setup
Visit `/` on the HTTP server to add an admin account.

8
config/index.js Normal file
View file

@ -0,0 +1,8 @@
require('dotenv').config();
module.exports = {
dbUrl: process.env.DB_URL || 'db',
defaultFailureRedirect: process.env.DEFAULT_FAILURE_REDIRECT || '/login',
port: process.env.PORT || 3000,
secret: process.env.SECRET || require('uuid/v4')(),
siteTitle: process.env.SITE_TITLE || 'Christmas Community'
};

57
index.js Normal file
View file

@ -0,0 +1,57 @@
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt-nodejs');
const flash = require('connect-flash');
const passport = require('passport');
const express = require('express');
const PouchDB = require('pouchdb');
const config = require('./config');
const logger = require('./logger');
const app = express();
const db = new PouchDB(config.dbUrl);
passport.use('local', new LocalStrategy(
(username, password, done) => {
db.get(username)
.then(doc => {
bcrypt.compare(password, doc.password, (err, correct) => {
if (err) return done(err);
if (!correct) return done(null, false, { message: 'Incorrect password' });
if (correct) return done(null, doc);
});
})
.catch(err => {
if (err.message === 'missing') return done(null, false, { message: 'Incorrect username.' });
return done(err);
});
}
));
passport.serializeUser((user, callback) => callback(null, user._id));
passport.deserializeUser((user, callback) => {
db.get(user)
.then(dbUser => callback(null, dbUser))
.catch(err => callback(err));
});
app.use(require('body-parser').urlencoded({ extended: true }));
app.use(require('express-session')({ secret: config.secret, resave: false, saveUninitialized: true }));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());
app.use(require('./middlewares/locals'));
app.use((req, res, next) => {
logger.log('express', `${req.ip} - ${req.method} ${req.originalUrl}`);
next();
});
app.set('view engine', 'pug');
app.use('/', require('./routes')(db));
app.listen(config.port, () => logger.success('express', `Express server started on port ${config.port}!`))

12
logger.js Normal file
View file

@ -0,0 +1,12 @@
const chalk = require('chalk');
const config = require('./config');
const colors = {log: 'blue', success: 'green', error: 'red', warn: 'yellow'};
// rewrite to use Object.keys()
for (let property in colors) {
if (colors.hasOwnProperty(property)) {
module.exports[property] = (type, msg) => {
console.log(chalk.keyword(colors[property])(`[ ${type.toUpperCase()} ] ${msg}`));
};
}
}

8
middlewares/locals.js Normal file
View file

@ -0,0 +1,8 @@
const config = require('../config');
module.exports = (req, res, next) => {
res.locals.successes = req.flash('success');
res.locals.errors = req.flash('error');
res.locals.config = config;
res.locals.req = req;
next();
};

11
middlewares/verifyAuth.js Normal file
View file

@ -0,0 +1,11 @@
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);
}
};
}

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "christmas-community",
"version": "1.0.0",
"description": "Christmas lists for communities",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js"
},
"author": "Wingysam <wingysam+git@gmail.com>",
"license": "MIT",
"dependencies": {
"bcrypt-nodejs": "^0.0.3",
"body-parser": "^1.18.3",
"chalk": "^2.4.1",
"connect-flash": "^0.1.1",
"dotenv": "^6.1.0",
"express": "^4.16.4",
"express-session": "^1.15.6",
"get-product-name": "^0.0.2",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"pouchdb": "^7.0.0",
"pug": "^2.0.3",
"uuid": "^3.3.2"
}
}

View file

@ -0,0 +1,60 @@
const verifyAuth = require('../../middlewares/verifyAuth');
const bcrypt = require('bcrypt-nodejs');
const express = require('express');
module.exports = (db) => {
const router = express.Router();
router.get('/', verifyAuth(), (req, res) => {
if (!req.user.admin) return res.redirect('/');
db.allDocs({ include_docs: true })
.then(docs => {
res.render('adminSettings', { title: 'Admin Settings', users: docs.rows })
})
.catch(err => { throw err; });
});
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,
password: newUserPasswordHash,
admin: false,
wishlist: []
});
req.flash('success', `Successfully added user ${req.body.newUserUsername}!`);
res.redirect('/admin-settings');
});
});
router.get('/remove/:userToRemove', verifyAuth(), (req, res) => {
if (!req.user.admin) return res.redirect('/');
res.render('remove', { userToRemove: req.params.userToRemove });
});
router.post('/remove/:userToRemove', verifyAuth(), async (req, res) => {
if (!req.user.admin) return res.redirect('/');
const doc = await db.get(req.params.userToRemove);
if (doc.admin) {
req.flash('error', 'Failed to remove: user is admin.');
return res.redirect('/admin-settings');
}
await db.remove(doc);
const docs = await db.allDocs({ include_docs: true });
for (let i = 0; i < docs.length; i++) {
for (let j = 0; j < docs[i].doc.wishlist.length; j++) {
if (docs[i].doc.wishlist[j].pledgedBy === req.params.userToRemove) {
docs[i].doc.wishlist[j].pledgedBy === undefined;
if (docs[i].doc.wishlist[j].addedBy === req.params.userToRemove) await db.remove(doc);
else await db.put(docs[i].doc);
}
}
}
req.flash('success', `Successfully removed user ${req.params.userToRemove}`);
res.redirect('/admin-settings')
});
return router;
};

37
routes/index.js Normal file
View file

@ -0,0 +1,37 @@
const verifyAuth = require('../middlewares/verifyAuth');
const express = require('express');
const path = require('path');
module.exports = (db) => {
const router = express.Router();
router.use('/', express.static(path.join(__dirname, '../static')));
router.get('/',
async (req, res, next) => {
dbInfo = await db.info();
if (dbInfo.doc_count === 0) {
res.redirect('/setup');
} else {
next();
}
},
verifyAuth(),
(req, res) => {
res.redirect('/wishlist');
}
);
router.use('/setup', require('./setup')(db));
router.use('/login', require('./login')());
router.use('/logout', require('./logout')());
router.use('/wishlist', require('./wishlist')(db));
router.use('/profile', require('./profile')(db));
router.use('/admin-settings', require('./adminSettings')(db));
return router;
}

29
routes/login/index.js Normal file
View file

@ -0,0 +1,29 @@
const passport = require('passport');
const express = require('express');
module.exports = () => {
const router = express.Router();
router.get('/',
(req, res) => {
if (req.isAuthenticated()) {
res.redirect('/');
} else {
res.render('login');
}
}
);
router.post(
'/',
(req, res, next) => {
next();
},
passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/login',
failureFlash: 'Invalid username or password'
})
);
return router;
};

14
routes/logout/index.js Normal file
View file

@ -0,0 +1,14 @@
const verifyAuth = require('../../middlewares/verifyAuth');
const express = require('express');
module.exports = () => {
const router = express.Router();
router.get('/', verifyAuth(), (req, res) => res.render('logout'));
router.post('/', (req, res) => {
req.logout();
res.redirect('/');
});
return router;
};

39
routes/profile/index.js Normal file
View file

@ -0,0 +1,39 @@
const verifyAuth = require('../../middlewares/verifyAuth');
const bcrypt = require('bcrypt-nodejs');
const express = require('express');
module.exports = (db) => {
const router = express.Router();
router.get('/', verifyAuth(), (req, res) => res.render('profile', { title: `Profile Settings - ${req.user._id}`}));
router.post('/', verifyAuth(), (req, res) => {
if (req.body.oldPassword && req.body.newPassword) {
bcrypt.compare(req.body.oldPassword, req.user.password, (err, correct) => {
if (err) throw err;
if (correct) {
bcrypt.hash(req.body.newPassword, null, null, (err, hash) => {
if (err) throw err;
db.get(req.user._id)
.then(doc => {
doc.password = hash;
db.put(doc)
.then(() => {
req.flash('success', 'Changes saved successfully!');
res.redirect('/profile');
})
.catch(err => { throw err; });
})
.catch(err => { throw err; });
});
} else {
req.flash('error', 'Incorrect old password');
res.redirect('/profile');
}
});
} else {
res.redirect('/profile');
}
});
return router;
};

39
routes/setup/index.js Normal file
View file

@ -0,0 +1,39 @@
const bcrypt = require('bcrypt-nodejs')
const express = require('express');
module.exports = (db) => {
const router = express.Router();
router.get('/',
async (req, res) => {
const dbInfo = await db.info();
if (dbInfo.doc_count === 0) {
res.render('setup', { title: 'Setup' });
} else {
res.redirect('/');
}
}
);
router.post('/',
async (req, res) => {
const dbInfo = await db.info();
if (dbInfo.doc_count === 0) {
bcrypt.hash(req.body.adminPassword, null, null, (err, adminPasswordHash) => {
if (err) throw err;
db.put({
_id: req.body.adminUsername,
password: adminPasswordHash,
admin: true,
wishlist: []
})
res.redirect('/');
});
} else {
res.redirect('/');
}
}
);
return router;
}

119
routes/wishlist/index.js Normal file
View file

@ -0,0 +1,119 @@
const verifyAuth = require('../../middlewares/verifyAuth');
const getProductName = require('get-product-name');
const bcrypt = require('bcrypt-nodejs');
const express = require('express');
const uuid = require('uuid/v4');
const totals = wishlist => {
let unpledged = 0;
let pledged = 0;
wishlist.forEach(wishItem => {
if (wishItem.pledgedBy) pledged += 1;
else unpledged += 1;
});
return { unpledged, pledged };
};
const ValidURL = (string) => { // Ty SO
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
module.exports = (db) => {
const router = express.Router();
router.get('/', verifyAuth(), async (req, res) => {
const docs = await db.allDocs({ include_docs: true })
res.render('wishlists', { title: 'Wishlists', users: docs.rows, totals})
});
router.get('/:user', verifyAuth(), async (req, res) => {
try {
const dbUser = await db.get(req.params.user);
res.render('wishlist', { title: `Wishlist - ${dbUser._id}`, wishlist: dbUser.wishlist });
} catch (error) {
res.redirect('/wishlist');
}
});
router.post('/:user', verifyAuth(), async (req, res) => {
const isUrl = ValidURL(req.body.itemUrlOrName);
const item = {};
let productData;
try {
if (isUrl) productData = await getProductName(req.body.itemUrlOrName);
} catch (err) {}
item.name = (productData ? productData.name : req.body.itemUrlOrName);
item.addedBy = req.user._id;
item.pledgedBy = (req.user._id === req.params.user ? undefined : req.user._id);
if (isUrl) item.url = req.body.itemUrlOrName;
item.id = uuid();
const doc = await db.get(req.params.user);
doc.wishlist.push(item);
await db.put(doc);
req.flash('success', (req.user._id === req.params.user ? 'Added item to wishlist' : `Pleged item for ${req.params.user}`));
res.redirect(`/wishlist/${req.params.user}`);
});
router.post('/:user/pledge/:itemId', verifyAuth(), async (req, res) => {
const docs = await db.allDocs({ include_docs: true });
for (let i = 0; i < docs.rows.length; i++) {
for (let j = 0; j < docs.rows[i].doc.wishlist.length; j++) {
if (docs.rows[i].doc.wishlist[j].id === req.params.itemId) {
if (docs.rows[i].doc.wishlist[j].pledgedBy !== undefined) {
req.flash('error', 'Item already pledged for');
return res.redirect(`/wishlist/${req.params.user}`);
}
docs.rows[i].doc.wishlist[j].pledgedBy = req.user._id;
await db.put(docs.rows[i].doc);
req.flash('success', 'Successfully pledged for item!');
return res.redirect(`/wishlist/${req.params.user}`);
}
}
}
});
router.post('/:user/unpledge/:itemId', verifyAuth(), async (req, res) => {
const docs = await db.allDocs({ include_docs: true });
for (let i = 0; i < docs.rows.length; i++) {
for (let j = 0; j < docs.rows[i].doc.wishlist.length; j++) {
if (docs.rows[i].doc.wishlist[j].id === req.params.itemId) {
if (docs.rows[i].doc.wishlist[j].pledgedBy !== req.user._id) {
req.flash('error', 'You did not pledge for this');
return res.redirect(`/wishlist/${req.params.user}`);
}
docs.rows[i].doc.wishlist[j].pledgedBy = undefined;
if (docs.rows[i].doc.wishlist[j].addedBy === req.user._id) docs.rows[i].doc.wishlist.pop(j);
await db.put(docs.rows[i].doc);
req.flash('success', 'Successfully unpledged for item');
return res.redirect(`/wishlist/${req.params.user}`);
}
}
}
req.flash('error', 'Failed to find item');
return res.redirect(`/wishlist/${req.params.user}`);
});
router.post('/:user/remove/:itemId', verifyAuth(), async (req, res) => {
if (req.user._id !== req.params.user) {
req.flash('error', 'Not correct user');
return res.redirect(`/wishlists/${req.params.user}`);
}
const doc = await db.get(req.user._id);
for (let i = 0; i < doc.wishlist.length; i++) {
if (doc.wishlist[i].id === req.params.itemId) {
doc.wishlist.pop(i);
await db.put(doc);
req.flash('success', 'Successfully removed from wishlist');
return res.redirect(`/wishlist/${req.params.user}`);
}
}
req.flash('error', 'Failed to find item');
return res.redirect(`/wishlist/${req.params.user}`);
});
return router;
};

14
static/css/main.css Normal file
View file

@ -0,0 +1,14 @@
@import "https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.0/css/bulma.min.css";
.inline {
display: inline;
}
.linkButton {
background: none;
border: none;
}
ul.noStyle li {
list-style-type: none;
}

BIN
static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

8
static/js/nav.js Normal file
View file

@ -0,0 +1,8 @@
window.onload = () => {
const burger = document.getElementById('navBarBurger');
const navBarMenu = document.getElementById('navBarMenu');
burger.addEventListener('click', () => {
burger.classList.toggle('is-active');
navBarMenu.classList.toggle('is-active');
});
};

30
views/adminSettings.pug Normal file
View file

@ -0,0 +1,30 @@
extends layout.pug
block content
h2 Users
each user in users
span.is-size-6.inline= user.id
if !user.doc.admin
a(href=`/admin-settings/remove/${user.id}`)
span.is-size-7.icon.has-text-danger
i.fas.fa-times
span.is-sr-only
Remove
br
h3 Add user
form(action='/admin-settings/add', method='POST')
.field
label.label Username
.control.has-icons-left
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')

View file

@ -0,0 +1,6 @@
if successes
each success in successes
p.has-text-success= success
if errors
each error in errors
p.has-text-danger= error

36
views/includes/navbar.pug Normal file
View file

@ -0,0 +1,36 @@
mixin navBarLink(href, title)
if href === req.path
a.is-active.navbar-item(href=href)= title
else
a.navbar-item(href=href)= title
nav.navbar(role='navigation', aria-label='main navigation')
.navbar-brand
if '/' === req.path
a.is-active.navbar-item(href='/')
img(src='/img/logo.png', alt='')
span #{config.siteTitle}
else
a.navbar-item(href='/')
img(src='/img/logo.png', alt='')
span #{config.siteTitle}
a.navbar-burger#navBarBurger(role='button', aria-label='menu', aria-expanded='false')
span(aria-hidden='true')
span(aria-hidden='true')
span(aria-hidden='true')
.navbar-menu#navBarMenu
.navbar-start
.navbar-end
if req.isAuthenticated()
.navbar-item.has-dropdown.is-hoverable
a.navbar-link= req.user._id
.navbar-dropdown
+navBarLink(`/wishlist/${req.user._id}`, 'My Wishlist')
+navBarLink('/profile', 'Profile')
if req.user.admin
+navBarLink('/admin-settings', 'Admin settings')
hr.navbar-divider
.navbar-item
form#logoutForm(action='/logout', method='POST')
button.linkButton(type='submit') Log Out
//-+navBarLink('javascript:document.getElementById("logoutForm").submit()', 'Log Out')
script(src="/js/nav.js")

28
views/layout.pug Normal file
View file

@ -0,0 +1,28 @@
doctype html
html(lang='en')
head
meta(name='viewport', content='width=device-width, initial-scale=1')
if title
title #{config.siteTitle} - #{title}
else
title #{config.siteTitle}
link(rel='stylesheet', href='/css/main.css')
noscript
link(
rel='stylesheet',
href='https://use.fontawesome.com/releases/v5.0.10/css/all.css',
integrity='sha384-+d0P83n9kaQMCwj8F4RJB66tzIwOKmrdb46+porD/OvrJ+37WqIM7UoBtwHO6Nlg',
crossorigin='anonymous'
)
script(defer, src='https://use.fontawesome.com/releases/v5.0.7/js/all.js')
body
include includes/navbar.pug
div.content#pageContent
section.section
div.container
if title
h1= config.siteTitle + ' - ' + title
else if title !== false
h1 #{config.siteTitle}
include includes/messages.pug
block content

20
views/login.pug Normal file
View file

@ -0,0 +1,20 @@
extends layout.pug
block content
form(method='POST')
.field
label.label Username
.control.has-icons-left
input.input(type='text', name='username', 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='password', placeholder='pa$$word!')
span.icon.is-small.is-left
i.fas.fa-lock
.field
.control
input.button.is-primary(type='submit' value='Log In')

7
views/logout.pug Normal file
View file

@ -0,0 +1,7 @@
extends layout.pug
block content
form(method='POST')
.field
.control
input.button.is-primary(type='submit' value='Log Out')

20
views/profile.pug Normal file
View file

@ -0,0 +1,20 @@
extends layout.pug
block content
form(method='POST')
.field
label.label Old Password (Required if changing password)
.control.has-icons-left
input.input(type='password', name='oldPassword', placeholder='pa$$word!')
span.icon.is-small.is-left
i.fas.fa-lock
.field
label.label New Password (Leave blank if not changing password)
.control.has-icons-left
input.input(type='password', name='newPassword', placeholder='pa$$word!')
span.icon.is-small.is-left
i.fas.fa-lock
.field
.control
input.button.is-primary(type='submit' value='Save')

7
views/remove.pug Normal file
View file

@ -0,0 +1,7 @@
extends layout.pug
block content
form(method='POST')
.field
.control
input.button.is-danger(type='submit' value=`Remove user ${userToRemove}`)

20
views/setup.pug Normal file
View file

@ -0,0 +1,20 @@
extends layout.pug
block content
h2 Admin User
form(action='/setup', method='POST')
.field
label.label Username
.control.has-icons-left
input.input(type='text', name='adminUsername', 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='adminPassword', placeholder='pa$$word!')
span.icon.is-small.is-left
i.fas.fa-lock
.field
.control
input.button.is-primary(type='submit' value='Set up!')

36
views/wishlist.pug Normal file
View file

@ -0,0 +1,36 @@
extends layout.pug
block content
for item in wishlist
if req.user._id === item.addedBy || req.params.user !== req.user._id
.box
if item.url
span
a(href=item.url, rel='noopener noreferrer', target='_blank')= (item.name ? item.name : item.url)
if req.params.user !== req.user._id && !item.pledgedBy
form(method='POST', action=`/wishlist/${req.params.user}/pledge/${item.id}`)
.field
.control
input.button.is-primary(type='submit' value='Pledge')
if item.pledgedBy === req.user._id
form(method='POST', action=`/wishlist/${req.params.user}/unpledge/${item.id}`)
.field
.control
input.button(type='submit' value='Unpledge')
if req.user._id === req.params.user
form(method='POST', action=`/wishlist/${req.params.user}/remove/${item.id}`)
.field
.control
input.button.is-warning(type='submit' value='Remove')
else
span= item.name
form(method='POST')
.field
label.label Item URL or Name
.control.has-icons-left
input.input(type='text', name='itemUrlOrName', placeholder='https://www.amazon.com/dp/B00ZV9RDKK')
span.icon.is-small.is-left
i.fas.fa-gift
.field
.control
input.button(type='submit' value=(req.user._id === req.params.user ? 'Add item to wishlist' : 'Pledge item'))

12
views/wishlists.pug Normal file
View file

@ -0,0 +1,12 @@
extends layout.pug
block content
ul.noStyle
each user in users
if req.user._id !== user.id
li
a(href=`/wishlist/${user.id}`)
.box
span= user.id
span= `: ${totals(user.doc.wishlist).pledged}/${user.doc.wishlist.length}`
progress.progress.is-info(value=totals(user.doc.wishlist).pledged, max=user.doc.wishlist.length)

1762
yarn.lock Normal file

File diff suppressed because it is too large Load diff