├── .gitignore ├── public └── img │ └── users │ └── default.jpg ├── utils ├── catchAsync.js ├── appError.js ├── apiFeatures.js └── email.js ├── views └── emails │ ├── passwordReset.pug │ ├── welcome.pug │ ├── baseEmail.pug │ └── _style.pug ├── routes └── userRoutes.js ├── package.json ├── controllers ├── handlerFactory.js ├── errorController.js ├── userController.js └── authController.js ├── app.js ├── bin └── www ├── models └── userModel.js └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | config.env -------------------------------------------------------------------------------- /public/img/users/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infouzi/Express-Advanced-user-skeleton/HEAD/public/img/users/default.jpg -------------------------------------------------------------------------------- /utils/catchAsync.js: -------------------------------------------------------------------------------- 1 | module.exports = catchAsync = (fn) => { 2 | return (req, res, next) => { 3 | fn(req, res, next).catch(next); 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /utils/appError.js: -------------------------------------------------------------------------------- 1 | class AppError extends Error { 2 | constructor(message, statusCode) { 3 | super(message); 4 | 5 | this.statusCode = statusCode; 6 | this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; 7 | this.isOperational = true; 8 | 9 | Error.captureStackTrace(this, this.constructor); 10 | } 11 | } 12 | 13 | module.exports = AppError; 14 | -------------------------------------------------------------------------------- /views/emails/passwordReset.pug: -------------------------------------------------------------------------------- 1 | extends baseEmail 2 | 3 | block content 4 | p Hi #{firstName}, 5 | p Forgot your password? Submit a PATCH request with your new password and passwordConfirm to: #{url}. 6 | p (Website for this action not yet implemented) 7 | table.btn.btn-primary( 8 | role='presentation', 9 | border='0', 10 | cellpadding='0', 11 | cellspacing='0' 12 | ) 13 | tbody 14 | tr 15 | td(align='left') 16 | table( 17 | role='presentation', 18 | border='0', 19 | cellpadding='0', 20 | cellspacing='0' 21 | ) 22 | tbody 23 | tr 24 | td 25 | a(href=`${url}`, target='_blank') Reset your password 26 | p If you didn't foget your password, please ignore this email! -------------------------------------------------------------------------------- /views/emails/welcome.pug: -------------------------------------------------------------------------------- 1 | extends baseEmail 2 | 3 | block content 4 | p Hi #{firstName}, 5 | p Welcome to Enterprise name, we're glad to have you 🎉🙏 6 | p We're all a big familiy here, so make sure to upload your user photo so we get to know you a bit better! 7 | table.btn.btn-primary( 8 | role='presentation', 9 | border='0', 10 | cellpadding='0', 11 | cellspacing='0' 12 | ) 13 | tbody 14 | tr 15 | td(align='left') 16 | table( 17 | role='presentation', 18 | border='0', 19 | cellpadding='0', 20 | cellspacing='0' 21 | ) 22 | tbody 23 | tr 24 | td 25 | a(href=`${url}`, target='_blank') Upload user photo 26 | p If you need any help with booking your next tour, please don't hesitate to contact me! 27 | p - Your name, CEO -------------------------------------------------------------------------------- /routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const userController = require('./../controllers/userController'); 3 | const authController = require('./../controllers/authController'); 4 | 5 | const router = express.Router(); 6 | 7 | router.post('/signup', authController.signup); 8 | router.post('/login', authController.login); 9 | router.post('/logout', authController.logout); 10 | router.post('/forgotPassword', authController.forgotPassword); 11 | router.patch('/resetPassword/:token', authController.resetPassword); 12 | 13 | router.use(authController.protect); 14 | 15 | router.get('/me', userController.getMe, userController.getUser); 16 | router.patch( 17 | '/updateMe', 18 | userController.uploadUserPhoto, 19 | userController.resizeUserPhoto, 20 | userController.updateMe 21 | ); 22 | router.delete('/deleteMe', userController.deleteMe); 23 | router.patch('/updateMyPassword', authController.updateMyPassword); 24 | 25 | router.use(authController.restrictTo('admin')); 26 | 27 | router 28 | .route('/') 29 | .get(userController.getAllUsers) 30 | .post(userController.createUser); 31 | 32 | router 33 | .route('/:id') 34 | .get(userController.getUser) 35 | .patch(userController.updateUser) 36 | .delete(userController.deleteUser); 37 | 38 | module.exports = router; 39 | -------------------------------------------------------------------------------- /utils/apiFeatures.js: -------------------------------------------------------------------------------- 1 | class ApiFeatures { 2 | constructor(query, queryStr) { 3 | this.query = query; 4 | this.queryStr = queryStr; 5 | } 6 | 7 | filter() { 8 | const queryObj = { ...this.queryStr }; 9 | const excludeFields = ['page', 'sort', 'limit', 'fields']; 10 | excludeFields.forEach((el) => delete queryObj[el]); 11 | 12 | // 1A) Advanced filtering 13 | 14 | let queryStr = JSON.stringify(queryObj); 15 | queryStr = queryStr.replace(/\b(gte|gt|lte|lt\b)/g, (match) => `$${match}`); 16 | 17 | this.query = this.query.find(JSON.parse(queryStr)); 18 | 19 | return this; 20 | } 21 | 22 | sort() { 23 | if (this.queryStr.sort) { 24 | const sortBy = this.queryStr.sort.split(',').join(' '); 25 | this.query = this.query.sort(sortBy); 26 | } else { 27 | this.query = this.query.sort('-createdAt'); 28 | } 29 | return this; 30 | } 31 | 32 | limitFields() { 33 | if (this.queryStr.fields) { 34 | const fields = this.queryStr.fields.split(',').join(' '); 35 | this.query = this.query.select(fields); 36 | } else { 37 | this.query = this.query.select('-__v'); 38 | } 39 | return this; 40 | } 41 | 42 | paginate() { 43 | const page = this.queryStr.page * 1 || 1; 44 | const limit = this.queryStr.limit * 1 || 100; 45 | const skip = (page - 1) * limit; 46 | 47 | this.query = this.query.skip(skip).limit(limit); 48 | 49 | return this; 50 | } 51 | } 52 | 53 | module.exports = ApiFeatures; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "advanced-user-skeleton", 3 | "version": "1.0.0", 4 | "description": "This is an advanced skeleton of a Nodejs./Express/MongoDB starter Webproject based on REST API. The main goal is to start any upcoming project with the \"user\" component all setup properly and not t…", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node ./bin/www", 8 | "start:dev": "nodemon ./bin/www", 9 | "start:prod": "set NODE_ENV=production&& nodemon ./bin/www" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Infouzi/Express-Advanced-user-skeleton.git" 14 | }, 15 | "keywords": [ 16 | "Nodejs", 17 | "Express", 18 | "Mongo", 19 | "Skeleton", 20 | "API", 21 | "Rest", 22 | "User", 23 | "management" 24 | ], 25 | "author": "Mohamed Fouzi Atamena", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/Infouzi/Express-Advanced-user-skeleton/issues" 29 | }, 30 | "homepage": "https://github.com/Infouzi/Express-Advanced-user-skeleton#readme", 31 | "dependencies": { 32 | "bcryptjs": "^2.4.3", 33 | "compression": "^1.7.4", 34 | "cookie-parser": "^1.4.5", 35 | "dotenv": "^8.2.0", 36 | "express": "^4.17.1", 37 | "express-mongo-sanitize": "^2.0.0", 38 | "express-rate-limit": "^5.1.3", 39 | "helmet": "^3.22.0", 40 | "hpp": "^0.2.3", 41 | "html-to-text": "^5.1.1", 42 | "http": "0.0.1-security", 43 | "jsonwebtoken": "^8.5.1", 44 | "mongoose": "^5.9.14", 45 | "morgan": "^1.10.0", 46 | "multer": "^1.4.2", 47 | "nodemailer": "^6.4.6", 48 | "pug": "^2.0.4", 49 | "sharp": "^0.25.3", 50 | "validator": "^13.0.0", 51 | "xss-clean": "^0.1.1" 52 | }, 53 | "devDependencies": { 54 | "debug": "^4.1.1", 55 | "nodemon": "^2.0.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /utils/email.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | const pug = require('pug'); 3 | const htmlToText = require('html-to-text'); 4 | 5 | module.exports = class Email { 6 | constructor(user, url) { 7 | this.to = user.email; 8 | this.firstName = user.name.split(' ')[0]; 9 | this.url = url; 10 | this.from = `Fouzi Otoman <${process.env.EMAIL_FROM}>`; 11 | } 12 | 13 | newTransport() { 14 | if (process.env.NODE_ENV === 'production') { 15 | return nodemailer.createTransport({ 16 | service: 'SendGrid', 17 | auth: { 18 | user: process.env.SENDGRID_USERNAME, 19 | pass: process.env.SENDGRID_PASSWORD, 20 | }, 21 | }); 22 | } else if (process.env.NODE_ENV === 'development') { 23 | return nodemailer.createTransport({ 24 | host: process.env.EMAIL_HOST, 25 | port: process.env.EMAIL_PORT, 26 | auth: { 27 | user: process.env.EMAIL_USERNAME, 28 | pass: process.env.EMAIL_PASSWORD, 29 | }, 30 | }); 31 | } 32 | } 33 | 34 | // Send the actual email 35 | async send(template, subject) { 36 | // 1) Render HTML based on a pug template 37 | const html = pug.renderFile( 38 | `${__dirname}/../views/emails/${template}.pug`, 39 | { 40 | firstName: this.firstName, 41 | url: this.url, 42 | subject, 43 | } 44 | ); 45 | 46 | // 2) Definne mails option 47 | const mailOptions = { 48 | from: this.from, 49 | to: this.to, 50 | subject, 51 | html, 52 | text: htmlToText.fromString(html), 53 | }; 54 | 55 | // 3) Create a transport and send email 56 | await this.newTransport().sendMail(mailOptions); 57 | } 58 | 59 | async sendWelcome() { 60 | await this.send('welcome', 'Welcome to Alternative Stories family'); 61 | } 62 | async sendPasswordReset() { 63 | await this.send( 64 | 'passwordReset', 65 | 'Your password reset token (valid for only 10 minutes' 66 | ); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /controllers/handlerFactory.js: -------------------------------------------------------------------------------- 1 | const catchAsync = require('./../utils/catchAsync'); 2 | const AppError = require('./../utils/appError'); 3 | const APIFeatures = require('./../utils/apiFeatures'); 4 | 5 | exports.getAll = (Model) => 6 | catchAsync(async (req, res, next) => { 7 | let filter = {}; 8 | 9 | const features = new APIFeatures(Model.find(filter), req.query) 10 | .filter() 11 | .sort() 12 | .limitFields() 13 | .paginate(); 14 | 15 | const doc = await features.query; 16 | 17 | res.status(200).json({ 18 | status: 'success', 19 | result: doc.length, 20 | data: { 21 | data: doc, 22 | }, 23 | }); 24 | }); 25 | 26 | exports.getOne = (Model, popOption) => 27 | catchAsync(async (req, res, next) => { 28 | let features = new APIFeatures( 29 | Model.findById(req.params.id), 30 | req.query 31 | ).limitFields(); 32 | 33 | if (popOption) features.query = features.query.populate(popOption); 34 | const doc = await features.query; 35 | 36 | if (!doc) return next(new AppError('No document with this ID', 404)); 37 | 38 | res.status(200).json({ 39 | status: 'success', 40 | data: { 41 | data: doc, 42 | }, 43 | }); 44 | }); 45 | 46 | exports.createOne = (Model) => 47 | catchAsync(async (req, res, next) => { 48 | const doc = await Model.create(req.body); 49 | res.status(201).json({ 50 | status: 'success', 51 | data: { 52 | data: doc, 53 | }, 54 | }); 55 | }); 56 | 57 | exports.updateOne = (Model) => 58 | catchAsync(async (req, res, next) => { 59 | const doc = await Model.findByIdAndUpdate(req.params.id, req.body, { 60 | new: true, 61 | runValidators: true, 62 | }); 63 | 64 | if (!doc) return next(new AppError('No document with this ID', 404)); 65 | 66 | res.status(200).json({ 67 | status: 'success', 68 | data: { 69 | data: doc, 70 | }, 71 | }); 72 | }); 73 | 74 | exports.deleteOne = (Model) => 75 | catchAsync(async (req, res, next) => { 76 | const doc = await Model.findByIdAndDelete(req.params.id); 77 | 78 | if (!doc) { 79 | return next(new AppError('No document found with that ID', 404)); 80 | } 81 | 82 | res.status(204).json({ 83 | status: 'success', 84 | data: null, 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const morgan = require('morgan'); 4 | 5 | const helmet = require('helmet'); 6 | const hpp = require('hpp'); 7 | const xss = require('xss-clean'); 8 | const mongoSanitize = require('express-mongo-sanitize'); 9 | const rateLimit = require('express-rate-limit'); 10 | const cookieParser = require('cookie-parser'); 11 | const compression = require('compression'); 12 | 13 | const globalErrorHandler = require('./controllers/errorController'); 14 | const AppError = require('./utils/appError'); 15 | 16 | const userRouter = require('./routes/userRoutes'); 17 | 18 | const app = express(); 19 | 20 | // 1) Middlewares 21 | 22 | app.set('view engine', 'pug'); 23 | app.set('views', path.join(__dirname, 'views')); 24 | 25 | /** 26 | * serving static files 27 | */ 28 | app.use(express.static(path.join(__dirname, 'public'))); 29 | /** 30 | * set security http headers 31 | */ 32 | app.use(helmet()); 33 | 34 | // A) General Middleware 35 | /** 36 | * Body parser, reading data from body into req.body 37 | */ 38 | app.use(express.json({ limit: '10kb' })); 39 | app.use(express.urlencoded({ extended: true, limit: '10kb' })); 40 | 41 | /** 42 | * Parse cookies 43 | */ 44 | app.use(cookieParser()); 45 | 46 | /** 47 | * log requests in Dev 48 | */ 49 | if (process.env.NODE_ENV === 'development') app.use(morgan('dev')); 50 | 51 | // B) Other Security Middleware 52 | /** 53 | * Limit requests from same IP 54 | */ 55 | const limiter = rateLimit({ 56 | max: 100, 57 | windowMs: 60 * 60 * 1000, 58 | message: 'Too many requests from this IP, please try again in an hour!', 59 | }); 60 | app.use('/api', limiter); 61 | 62 | /** 63 | * Data sanitization against NoSQL query Injection 64 | */ 65 | app.use(mongoSanitize()); 66 | 67 | /** 68 | * Avoid http parameter pollution 69 | */ 70 | app.use( 71 | hpp({ 72 | whitelist: ['price'], 73 | }) 74 | ); 75 | 76 | /** 77 | * Avoid xss injection 78 | */ 79 | app.use(xss()); 80 | 81 | // C) Compression using gzip 82 | app.use(compression()); 83 | 84 | // 2) Routes 85 | app.use('/api/v1/users', userRouter); 86 | 87 | 88 | // catch 404 and forward to error handler 89 | app.use(function (req, res, next) { 90 | next(new AppError(`Can't find ${req.originalUrl} on this server`, 404)); 91 | }); 92 | 93 | // 3) Global Error Handler 94 | app.use(globalErrorHandler); 95 | 96 | // 4) exports app 97 | module.exports = app; 98 | -------------------------------------------------------------------------------- /controllers/errorController.js: -------------------------------------------------------------------------------- 1 | const AppError = require('./../utils/appError'); 2 | 3 | const handleCastErrorDB = (err) => { 4 | const message = `Invalid ${err.path}: ${err.value}`; 5 | return new AppError(message, 400); 6 | }; 7 | const handleDuplicateFieldsDB = (err) => { 8 | const value = err.errmsg.match(/(["'])(\\?.)*?\1/)[0]; 9 | const message = `Duplicate field value ${value}. Please use another value!`; 10 | return new AppError(message, 400); 11 | }; 12 | const handleValidationErrorDB = (err) => { 13 | const errors = Object.values(err.errors).map((el) => el.message); 14 | 15 | const message = `Invalid input data. ${errors.join('. ')}`; 16 | return new AppError(message, 400); 17 | }; 18 | 19 | const handleJWTError = () => 20 | new AppError('Invalid token. Please log in again!', 401); 21 | 22 | const handleJWTExpiresdError = () => 23 | new AppError('Your token has expired! Please log in again.', 401); 24 | 25 | const sendErrorDev = (err, req, res) => { 26 | res.status(err.statusCode).json({ 27 | status: err.status, 28 | error: err, 29 | message: err.message, 30 | stack: err.stack, 31 | }); 32 | }; 33 | 34 | const sendErrorProd = (err, req, res) => { 35 | if (err.isOperational) { 36 | res.status(err.statusCode).json({ 37 | status: err.status, 38 | message: err.message, 39 | }); 40 | } else { 41 | // 1) Log the prod error 42 | console.error('ERROR 💥💥💥 ', err); 43 | // 2) Send appropriate error message 44 | res.status(500).json({ 45 | status: 'error', 46 | message: 'Oops! Something went wrong.', 47 | }); 48 | } 49 | }; 50 | 51 | module.exports = (err, req, res, next) => { 52 | // initialize for non-generated errors 53 | err.statusCode = err.statusCode || 500; 54 | err.status = err.status || 'error'; 55 | 56 | if (process.env.NODE_ENV === 'development') { 57 | sendErrorDev(err, req, res); 58 | } else if (process.env.NODE_ENV === 'production') { 59 | let error = { ...err }; 60 | error.message = err.message; 61 | 62 | if (error.name === 'CastError') error = handleCastErrorDB(err); 63 | if (error.code === 11000) error = handleDuplicateFieldsDB(err); 64 | if (error.name === 'ValidationError') error = handleValidationErrorDB(err); 65 | if (error.name === 'JsonWebTokenError') error = handleJWTError(); 66 | if (error.name === 'TokenExpiredError') error = handleJWTExpiresdError(); 67 | 68 | sendErrorProd(error, req, res); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /views/emails/baseEmail.pug: -------------------------------------------------------------------------------- 1 | //- Email template adapted from https://github.com/leemunroe/responsive-html-email-template 2 | //- Converted from HTML using https://html2pug.now.sh/ 3 | 4 | doctype html 5 | html 6 | head 7 | meta(name='viewport', content='width=device-width') 8 | meta(http-equiv='Content-Type', content='text/html; charset=UTF-8') 9 | title= subject 10 | 11 | include _style 12 | 13 | body 14 | table.body( 15 | role='presentation', 16 | border='0', 17 | cellpadding='0', 18 | cellspacing='0' 19 | ) 20 | tbody 21 | tr 22 | td 23 | td.container 24 | .content 25 | // START CENTERED WHITE CONTAINER 26 | table.main(role='presentation') 27 | // START MAIN AREA 28 | tbody 29 | tr 30 | td.wrapper 31 | table( 32 | role='presentation', 33 | border='0', 34 | cellpadding='0', 35 | cellspacing='0' 36 | ) 37 | tbody 38 | tr 39 | td 40 | // CONTENT 41 | block content 42 | 43 | // START FOOTER 44 | .footer 45 | table( 46 | role='presentation', 47 | border='0', 48 | cellpadding='0', 49 | cellspacing='0' 50 | ) 51 | tbody 52 | tr 53 | td.content-block 54 | span.apple-link Enterprise-name Inc, Address 55 | br 56 | | 57 | | Don't like these emails? 58 | a(href='#') Unsubscribe 59 | //- td   60 | -------------------------------------------------------------------------------- /controllers/userController.js: -------------------------------------------------------------------------------- 1 | const multer = require('multer'); 2 | const sharp = require('sharp'); 3 | 4 | const User = require('./../models/userModel'); 5 | const catchAsync = require('./../utils/catchAsync'); 6 | const AppError = require('./../utils/appError'); 7 | const factory = require('./../controllers/handlerFactory'); 8 | 9 | // functions 10 | const filterObj = (data, ...allowedFields) => { 11 | let filter = {}; 12 | Object.keys(data).forEach((el) => { 13 | if (allowedFields.includes(el)) filter[el] = data[el]; 14 | }); 15 | 16 | return filter; 17 | }; 18 | 19 | const multerStorage = multer.memoryStorage(); 20 | const multerFilter = (req, file, cb) => { 21 | if (file.mimetype.startsWith('image')) { 22 | cb(null, true); 23 | } else { 24 | cb(new AppError('Not an image! Please upload only images.', 400), false); 25 | } 26 | }; 27 | 28 | const upload = multer({ 29 | storage: multerStorage, 30 | fileFilter: multerFilter, 31 | }); 32 | 33 | // 1) Middleware 34 | exports.getMe = (req, res, next) => { 35 | req.params.id = req.user.id; 36 | next(); 37 | }; 38 | 39 | exports.uploadUserPhoto = upload.single('photo'); 40 | exports.resizeUserPhoto = catchAsync(async (req, res, next) => { 41 | if (!req.file) next(); 42 | 43 | req.file.filename = `user-${req.user.id}-${Date.now()}.jpeg`; 44 | 45 | await sharp(req.file.buffer) 46 | .resize(500, 500) 47 | .toFormat('jpeg') 48 | .jpeg({ quality: 90 }) 49 | .toFile(`public/img/users/${req.file.filename}`); 50 | 51 | next(); 52 | }); 53 | 54 | // 2) Handlers 55 | exports.updateMe = catchAsync(async (req, res, next) => { 56 | // 1) Create error if user POSTs password data 57 | if (req.body.password || req.body.passwordConfirm) { 58 | return next( 59 | new AppError( 60 | 'This route is not for password updates. Please use /updateMyPassword', 61 | 400 62 | ) 63 | ); 64 | } 65 | // 2) Filter out unwanted data 66 | const filteredBody = filterObj(req.body, 'name', 'email'); 67 | if (req.file) filteredBody.photo = req.file.filename; 68 | // 3) Update user document 69 | const updatedUser = await User.findByIdAndUpdate(req.user.id, filteredBody, { 70 | new: true, 71 | runValidators: true, 72 | }); 73 | 74 | res.status(200).json({ 75 | status: 'success', 76 | data: { 77 | user: updatedUser, 78 | }, 79 | }); 80 | }); 81 | exports.deleteMe = catchAsync(async (req, res, next) => { 82 | await User.findByIdAndUpdate(req.user.id, { active: false }); 83 | 84 | res.status(204).json({ 85 | status: 'success', 86 | data: null, 87 | }); 88 | }); 89 | 90 | // CRUD 91 | exports.getAllUsers = factory.getAll(User); 92 | exports.getUser = factory.getOne(User); 93 | exports.createUser = factory.createOne(User); 94 | exports.updateUser = factory.updateOne(User); 95 | exports.deleteUser = factory.deleteOne(User); 96 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | const mongoose = require('mongoose'); 5 | const http = require('http'); 6 | const debug = require('debug')('canevas:server'); 7 | const dotenv = require('dotenv').config({path: __dirname + '/../config.env'}); 8 | 9 | /** 10 | * UNCAUGHT EXCPETIONS. Mostlfy for sporadic errors 11 | */ 12 | process.on('uncaughtException', err => { 13 | console.log('UNHANDLED EXCEPTION! 💥💥💥 Shutting down...'); 14 | console.log(err.name, err.message); 15 | process.exit(1); 16 | }); 17 | 18 | const app = require('./../app'); 19 | /** 20 | * Database Connection 21 | */ 22 | const DB = process.env.DATABASE.replace('', process.env.DATABASE_PASSWORD); 23 | 24 | // mongoose.connect(DB, { 25 | mongoose.connect(process.env.DATABASE_LOCAL, { 26 | useNewUrlParser: true, 27 | useCreateIndex: true, 28 | useFindAndModify: false, 29 | useUnifiedTopology: true, 30 | 31 | }).then(() => console.log('Database connection successful')); 32 | 33 | /** 34 | * Get port from environment and store in Express. 35 | */ 36 | const port = normalizePort(process.env.PORT || 3000); 37 | app.set('port', port); 38 | 39 | /** 40 | * Create HTTP server. 41 | */ 42 | const server = http.createServer(app); 43 | 44 | /** 45 | * Listen on provided port, on all network interfaces. 46 | */ 47 | server.listen(port); 48 | server.on('error', onError); 49 | server.on('listening', onListening); 50 | 51 | /** 52 | * Unhandled Rejections, mostly for promises errors. 53 | */ 54 | 55 | process.on('unhandledRejection', err => { 56 | console.log('UNHANDLED REJECTIONS! 💥💥💥 Shutting down...'); 57 | console.log(err.name, err.message); 58 | server.close(() => { 59 | process.exit(1); 60 | }); 61 | }); 62 | 63 | /** 64 | * Normalize a port into a number, string, or false. 65 | */ 66 | 67 | function normalizePort(val) { 68 | const port = parseInt(val, 10); 69 | 70 | if (isNaN(port)) { 71 | // named pipe 72 | return val; 73 | } 74 | 75 | if (port >= 0) { 76 | // port number 77 | return port; 78 | } 79 | return false; 80 | } 81 | 82 | /** 83 | * Event listener for HTTP server "error" event. 84 | */ 85 | function onError(error) { 86 | if (error.syscall !== 'listen') { 87 | throw error; 88 | } 89 | 90 | const bind = typeof port === 'string' 91 | ? 'Pipe ' + port 92 | : 'Port ' + port; 93 | 94 | // handle specific listen errors with friendly messages 95 | switch (error.code) { 96 | case 'EACCES': 97 | console.error(bind + ' requires elevated privileges'); 98 | process.exit(1); 99 | break; 100 | case 'EADDRINUSE': 101 | console.error(bind + ' is already in use'); 102 | process.exit(1); 103 | break; 104 | default: 105 | throw error; 106 | } 107 | } 108 | 109 | /** 110 | * Event listener for HTTP server "listening" event. 111 | */ 112 | 113 | function onListening() { 114 | 115 | const addr = server.address(); 116 | const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port; 117 | // debug('Listening on %o ', bind); 118 | console.log('Listening on %o ', bind); 119 | 120 | } -------------------------------------------------------------------------------- /models/userModel.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const mongoose = require('mongoose'); 3 | const validator = require('validator'); 4 | const bcrypt = require('bcryptjs'); 5 | 6 | const userSchema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: String, 10 | required: [true, 'Please, tell us your name!'], 11 | unique: true, 12 | }, 13 | email: { 14 | type: String, 15 | required: [true, 'Please provide your email!'], 16 | unique: true, 17 | lowercase: true, 18 | validate: [ 19 | validator.default.isEmail, 20 | 'Please provide a valid email address!', 21 | ], 22 | }, 23 | password: { 24 | type: String, 25 | required: [true, 'Please provide a password'], 26 | minLength: 8, 27 | select: false, 28 | }, 29 | passwordConfirm: { 30 | type: String, 31 | required: [true, 'Please confirm your password'], 32 | validate: { 33 | // This only works on CREATE and SAVE 34 | validator: function (el) { 35 | return el === this.password; 36 | }, 37 | message: 'Passwords are not the same', 38 | }, 39 | }, 40 | passwordChangedAt: Date, 41 | passwordResetToken: String, 42 | passwordResetExpires: Date, 43 | photo: { 44 | type: String, 45 | default: 'default.jpg', 46 | }, 47 | role: { 48 | type: String, 49 | enum: ['user', 'admin'], 50 | default: 'user', 51 | }, 52 | active: { 53 | type: Boolean, 54 | default: true, 55 | select: false, 56 | }, 57 | }, 58 | { 59 | toJSON: { virtuals: true }, 60 | toObject: { virtuals: true }, 61 | } 62 | ); 63 | 64 | /** 65 | * Pre save Middleware for hashing the new document's password 66 | */ 67 | userSchema.pre('save', async function (next) { 68 | // Only run if password is modified 69 | if (!this.isModified('password')) return next(); 70 | 71 | // Hash new password 72 | this.password = await bcrypt.hash(this.password, 12); 73 | 74 | // Delete passwordConfirm field 75 | this.passwordConfirm = undefined; 76 | next(); 77 | }); 78 | 79 | /** 80 | * Pre save Middleware for updating passwordChangedAt in case password updated 81 | */ 82 | userSchema.pre('save', function (next) { 83 | if (!this.isModified('password') || this.isNew) return next(); 84 | 85 | this.passwordChangedAt = Date.now() - 1000; 86 | next(); 87 | }); 88 | 89 | /** 90 | * Pre find Query Middleware for find queries to eliminate inactive users 91 | */ 92 | userSchema.pre(/^find/, function (next) { 93 | this.find({ active: { $ne: false } }); 94 | next(); 95 | }); 96 | 97 | /** 98 | * Document function to verify if passwords are correct 99 | */ 100 | userSchema.methods.correctPassword = async function ( 101 | candidatePassword, 102 | userPassword 103 | ) { 104 | return await bcrypt.compare(candidatePassword, userPassword); 105 | }; 106 | 107 | userSchema.methods.changedPasswordAfter = function (JWTTimestamp) { 108 | if (this.passwordChangedAt) { 109 | const changedTimeStamp = parseInt( 110 | this.passwordChangedAt.getTime() / 1000, 111 | 10 112 | ); 113 | return changedTimeStamp > JWTTimestamp; 114 | } 115 | 116 | // False means NOT changed 117 | return false; 118 | }; 119 | 120 | userSchema.methods.createPasswordResetToken = function () { 121 | const resetToken = crypto.randomBytes(32).toString('hex'); 122 | this.passwordResetToken = crypto 123 | .createHash('sha256') 124 | .update(resetToken) 125 | .digest('hex'); 126 | 127 | // valid for 10 minutes 128 | this.passwordResetExpires = Date.now() + 10 * 60 * 1000; 129 | 130 | return resetToken; 131 | }; 132 | 133 | const User = mongoose.model('User', userSchema); 134 | 135 | module.exports = User; 136 | -------------------------------------------------------------------------------- /controllers/authController.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const { promisify } = require('util'); 3 | const jwt = require('jsonwebtoken'); 4 | const mongoose = require('mongoose'); 5 | const AppError = require('./../utils/appError'); 6 | const catchAsync = require('./../utils/catchAsync'); 7 | const Email = require('./../utils/email'); 8 | const User = require('./../models/userModel'); 9 | 10 | const signToken = (id) => { 11 | return jwt.sign({ id }, process.env.JWT_SECRET, { 12 | expiresIn: process.env.JWT_EXPIRES_IN, 13 | }); 14 | }; 15 | 16 | const createAndSendToken = (user, statusCode, req, res) => { 17 | // A) create and sign Token 18 | const token = signToken(user._id); 19 | // B) send cookie containing the token 20 | const cookieOptions = { 21 | expires: new Date( 22 | Date.now() + process.env.JWT_COOKIE_EXPIRES_IN * 24 * 60 * 60 * 1000 23 | ), 24 | httpOnly: true, 25 | }; 26 | if (process.env.NODE_ENV === 'production') cookieOptions.secure = true; 27 | 28 | res.cookie('jwt', token, cookieOptions); 29 | // C) Remove the password from the output 30 | user.password = undefined; 31 | 32 | // D) Send response back 33 | res.status(statusCode).json({ 34 | status: 'success', 35 | token, 36 | data: { 37 | user, 38 | }, 39 | }); 40 | }; 41 | 42 | exports.protect = catchAsync(async (req, res, next) => { 43 | // 1)Getting token and check if it exists 44 | 45 | let token; 46 | if ( 47 | req.headers.authorization && 48 | req.headers.authorization.startsWith('Bearer') 49 | ) { 50 | token = req.headers.authorization.split(' ')[1]; 51 | } /*else if (req.cookies.jwt) { 52 | token = req.cookies.jwt; 53 | }*/ 54 | 55 | if (!token) { 56 | return next( 57 | new AppError('You are not logged in! Please log in to get access.', 401) 58 | ); 59 | } 60 | // 2) Verification of token 61 | const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET); 62 | // 3) Check if user still exists 63 | const currentUser = await User.findById(decoded.id); 64 | 65 | if (!currentUser) { 66 | return next( 67 | new AppError('The user belonging to this token does no longer exist', 401) 68 | ); 69 | } 70 | // 4) Check if user changed password after the JWT was issued 71 | if (currentUser.changedPasswordAfter(decoded.iat)) { 72 | return next( 73 | new AppError('User recently changed password! Please log in again.', 401) 74 | ); 75 | } 76 | 77 | // 5) Everything Ok, GRANT ACCESS 78 | req.user = currentUser; 79 | next(); 80 | }); 81 | 82 | exports.restrictTo = (...roles) => { 83 | return (req, res, next) => { 84 | // roles['user', 'admin'] 85 | if (!roles.includes(req.user.role)) { 86 | return next( 87 | new AppError('You do not have permission to perform this action', 403) 88 | ); 89 | } 90 | next(); 91 | }; 92 | }; 93 | 94 | exports.signup = catchAsync(async (req, res, next) => { 95 | // 1) Save user to database 96 | const newUser = await User.create(req.body); 97 | 98 | // 2) Send Welcome Email 99 | const url = `${req.protocol}://${req.get('host')}/me`; 100 | await new Email(newUser, url).sendWelcome(); 101 | // 3) send token in cookie and new created user 102 | createAndSendToken(newUser, 201, req, res); 103 | }); 104 | 105 | exports.login = catchAsync(async (req, res, next) => { 106 | const { email, password } = req.body; 107 | 108 | // 1) Check if email and password exists 109 | if (!email || !password) { 110 | return next(new AppError('Please provide email and password!', 400)); 111 | } 112 | 113 | // 2) Check if email exists and password is correct 114 | const user = await User.findOne({ email }).select('+password'); 115 | 116 | if (!user || !(await user.correctPassword(password, user.password))) { 117 | return next(new AppError('Incorrect email or password', 401)); 118 | } 119 | // 3) If everything Ok, send Token to client 120 | createAndSendToken(user, 200, req, res); 121 | }); 122 | 123 | exports.logout = (req, res) => { 124 | res.cookie('jwt', 'loggedout', { 125 | expires: new Date(Date.now() + 10 * 1000), 126 | httpOnly: true, 127 | }); 128 | res.status(200).json({ status: 'success' }); 129 | }; 130 | 131 | exports.forgotPassword = catchAsync(async (req, res, next) => { 132 | // 1) Get user based on posted email 133 | const user = await User.findOne({ email: req.body.email }); 134 | if (!user) 135 | return next(new AppError('There is no user with that email address.', 404)); 136 | 137 | // 2) create ResetToken 138 | const resetToken = user.createPasswordResetToken(); 139 | await user.save({ validateBeforeSave: false }); 140 | const resetUrl = `${req.protocol}://${req.get( 141 | 'host' 142 | )}/api/v1/users/resetPassword/${resetToken}`; 143 | 144 | // 3) Send reset token via email 145 | try { 146 | await new Email(user, resetUrl).sendPasswordReset(); 147 | 148 | res.status(200).json({ 149 | status: 'success', 150 | message: 'Token sent to email!', 151 | }); 152 | } catch (e) { 153 | user.passwordResetToken = undefined; 154 | user.passwordResetExpires = undefined; 155 | await user.save({ validateBeforeSave: false }); 156 | 157 | return next( 158 | new AppError( 159 | 'There was an error sending the email. Try again later!', 160 | 500 161 | ) 162 | ); 163 | } 164 | }); 165 | 166 | exports.resetPassword = catchAsync(async (req, res, next) => { 167 | // 1) Get a user based on the token 168 | const hashedToken = crypto 169 | .createHash('sha256') 170 | .update(req.params.token) 171 | .digest('hex'); 172 | 173 | // Get user by resetToken and verify if his resetExpires > now 174 | const user = await User.findOne({ 175 | passwordResetToken: hashedToken, 176 | passwordResetExpires: { $gt: Date.now() }, 177 | }); 178 | 179 | // 2) If token has not expired, and there is a user, set the new password 180 | if (!user) { 181 | return next(new AppError('Token is invalid or has expired', 400)); 182 | } 183 | 184 | user.password = req.body.password; 185 | user.passwordConfirm = req.body.passwordConfirm; 186 | user.passwordResetToken = undefined; 187 | user.passwordResetExpires = undefined; 188 | await user.save(); 189 | 190 | // 4) Log the user in, send JWT 191 | createAndSendToken(user, 200, req, res); 192 | }); 193 | 194 | exports.updateMyPassword = catchAsync(async (req, res, next) => { 195 | // 1) Get the user from the collection 196 | const user = await User.findById(req.user.id).select('+password'); 197 | 198 | // 2) Check if POSTed password is correct 199 | if ( 200 | !user || 201 | !(await user.correctPassword(req.body.passwordCurrent, user.password)) 202 | ) { 203 | return next(new AppError('Your current password is wrong!', 401)); 204 | } 205 | // 3) update password 206 | user.password = req.body.password; 207 | user.passwordConfirm = req.body.passwordConfirm; 208 | 209 | if (await user.save()) { 210 | // 4) Log user in and send JWT 211 | createAndSendToken(user, 200, req, res); 212 | } 213 | }); 214 | -------------------------------------------------------------------------------- /views/emails/_style.pug: -------------------------------------------------------------------------------- 1 | style. 2 | img { 3 | border: none; 4 | -ms-interpolation-mode: bicubic; 5 | max-width: 100%; 6 | } 7 | body { 8 | background-color: #f6f6f6; 9 | font-family: sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | font-size: 14px; 12 | line-height: 1.4; 13 | margin: 0; 14 | padding: 0; 15 | -ms-text-size-adjust: 100%; 16 | -webkit-text-size-adjust: 100%; 17 | } 18 | table { 19 | border-collapse: separate; 20 | mso-table-lspace: 0pt; 21 | mso-table-rspace: 0pt; 22 | width: 100%; } 23 | table td { 24 | font-family: sans-serif; 25 | font-size: 14px; 26 | vertical-align: top; 27 | } 28 | .body { 29 | background-color: #f6f6f6; 30 | width: 100%; 31 | } 32 | .container { 33 | display: block; 34 | margin: 0 auto !important; 35 | /* makes it centered */ 36 | max-width: 580px; 37 | padding: 10px; 38 | width: 580px; 39 | } 40 | .content { 41 | box-sizing: border-box; 42 | display: block; 43 | margin: 0 auto; 44 | max-width: 580px; 45 | padding: 10px; 46 | } 47 | .main { 48 | background: #ffffff; 49 | border-radius: 3px; 50 | width: 100%; 51 | } 52 | .wrapper { 53 | box-sizing: border-box; 54 | padding: 20px; 55 | } 56 | .content-block { 57 | padding-bottom: 10px; 58 | padding-top: 10px; 59 | } 60 | .footer { 61 | clear: both; 62 | margin-top: 10px; 63 | text-align: center; 64 | width: 100%; 65 | } 66 | .footer td, 67 | .footer p, 68 | .footer span, 69 | .footer a { 70 | color: #999999; 71 | font-size: 12px; 72 | text-align: center; 73 | } 74 | h1, 75 | h2, 76 | h3, 77 | h4 { 78 | color: #000000; 79 | font-family: sans-serif; 80 | font-weight: 400; 81 | line-height: 1.4; 82 | margin: 0; 83 | margin-bottom: 30px; 84 | } 85 | h1 { 86 | font-size: 35px; 87 | font-weight: 300; 88 | text-align: center; 89 | text-transform: capitalize; 90 | } 91 | p, 92 | ul, 93 | ol { 94 | font-family: sans-serif; 95 | font-size: 14px; 96 | font-weight: normal; 97 | margin: 0; 98 | margin-bottom: 15px; 99 | } 100 | p li, 101 | ul li, 102 | ol li { 103 | list-style-position: inside; 104 | margin-left: 5px; 105 | } 106 | a { 107 | color: #55c57a; 108 | text-decoration: underline; 109 | } 110 | .btn { 111 | box-sizing: border-box; 112 | width: 100%; } 113 | .btn > tbody > tr > td { 114 | padding-bottom: 15px; } 115 | .btn table { 116 | width: auto; 117 | } 118 | .btn table td { 119 | background-color: #ffffff; 120 | border-radius: 5px; 121 | text-align: center; 122 | } 123 | .btn a { 124 | background-color: #ffffff; 125 | border: solid 1px #55c57a; 126 | border-radius: 5px; 127 | box-sizing: border-box; 128 | color: #55c57a; 129 | cursor: pointer; 130 | display: inline-block; 131 | font-size: 14px; 132 | font-weight: bold; 133 | margin: 0; 134 | padding: 12px 25px; 135 | text-decoration: none; 136 | text-transform: capitalize; 137 | } 138 | .btn-primary table td { 139 | background-color: #55c57a; 140 | } 141 | .btn-primary a { 142 | background-color: #55c57a; 143 | border-color: #55c57a; 144 | color: #ffffff; 145 | } 146 | .last { 147 | margin-bottom: 0; 148 | } 149 | .first { 150 | margin-top: 0; 151 | } 152 | .align-center { 153 | text-align: center; 154 | } 155 | .align-right { 156 | text-align: right; 157 | } 158 | .align-left { 159 | text-align: left; 160 | } 161 | .clear { 162 | clear: both; 163 | } 164 | .mt0 { 165 | margin-top: 0; 166 | } 167 | .mb0 { 168 | margin-bottom: 0; 169 | } 170 | .preheader { 171 | color: transparent; 172 | display: none; 173 | height: 0; 174 | max-height: 0; 175 | max-width: 0; 176 | opacity: 0; 177 | overflow: hidden; 178 | mso-hide: all; 179 | visibility: hidden; 180 | width: 0; 181 | } 182 | .powered-by a { 183 | text-decoration: none; 184 | } 185 | hr { 186 | border: 0; 187 | border-bottom: 1px solid #f6f6f6; 188 | margin: 20px 0; 189 | } 190 | @media only screen and (max-width: 620px) { 191 | table[class=body] h1 { 192 | font-size: 28px !important; 193 | margin-bottom: 10px !important; 194 | } 195 | table[class=body] p, 196 | table[class=body] ul, 197 | table[class=body] ol, 198 | table[class=body] td, 199 | table[class=body] span, 200 | table[class=body] a { 201 | font-size: 16px !important; 202 | } 203 | table[class=body] .wrapper, 204 | table[class=body] .article { 205 | padding: 10px !important; 206 | } 207 | table[class=body] .content { 208 | padding: 0 !important; 209 | } 210 | table[class=body] .container { 211 | padding: 0 !important; 212 | width: 100% !important; 213 | } 214 | table[class=body] .main { 215 | border-left-width: 0 !important; 216 | border-radius: 0 !important; 217 | border-right-width: 0 !important; 218 | } 219 | table[class=body] .btn table { 220 | width: 100% !important; 221 | } 222 | table[class=body] .btn a { 223 | width: 100% !important; 224 | } 225 | table[class=body] .img-responsive { 226 | height: auto !important; 227 | max-width: 100% !important; 228 | width: auto !important; 229 | } 230 | } 231 | @media all { 232 | .ExternalClass { 233 | width: 100%; 234 | } 235 | .ExternalClass, 236 | .ExternalClass p, 237 | .ExternalClass span, 238 | .ExternalClass font, 239 | .ExternalClass td, 240 | .ExternalClass div { 241 | line-height: 100%; 242 | } 243 | .apple-link a { 244 | color: inherit !important; 245 | font-family: inherit !important; 246 | font-size: inherit !important; 247 | font-weight: inherit !important; 248 | line-height: inherit !important; 249 | text-decoration: none !important; 250 | } 251 | .btn-primary table td:hover { 252 | background-color: #2e864b !important; 253 | } 254 | .btn-primary a:hover { 255 | background-color: #2e864b !important; 256 | border-color: #2e864b !important; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Express-advanced-user-skeleton 2 | 3 | This project is for every web developer using Nodejs. Express, MongoDB, who wants to start a project, but, don't want to waste time on writing the logic behind managing users. 4 | 5 | When starting some personal projects that share the same "user" entity, i found that it could be useful to have some "reusable" code, that could be easily used as "base" for managing users and also applies some best practise techniques. 6 | 7 | The user have authentication abilities using Json WebToken. Some Authorization functionalities are available too. 8 | 9 | ## Project Features 10 | This project focus on giving "user management" out of the box to reuse in future projects. To do so, many "best practises" were implemented, which resulted in having a bunch of files, which, in first sight, could be overwhelming. For this reason, this readme. is fragmented in the upcoming sections: 11 | 12 | 1. `Project Structure` 13 | 2. `Error Handling` 14 | 3. `Common features` && `Managing User entity` 15 | 4. `Security practise` 16 | 5. `Authenticating users` && `Authorization` 17 | 6. `Installation` 18 | 7. `Usage` 19 | 8. `Contributing` 20 | 21 | ## 1. Project Structure 22 | ```bash 23 | . 24 | ├── app.js # The Expess configuration with all the "Middlewares" and "Routes". 25 | ├── _bin 26 | │ └── www # The server to be run. Create the server with configuration from app.js object. 27 | ├── _controllers 28 | │ ├── authcontroller.js # handler for authentication/authorization/signup/login/forget-reset-update password 29 | │ ├── errorcontroller.js # the Global error handling controller ( middleware) 30 | │ ├── handleFactory.js # Factory handler to reuse the same basic CRUD functions with different entities (user...etc) 31 | │ └── usercontroller.js # Crud operations on any user, current user and managing photo uploading/resizing 32 | ├── _models 33 | │ └── userModel.js # Mongoose Schema model of a User with hashed password of course 34 | ├── _public # The public folder containing resources/assets 35 | │ ├── _img 36 | │ │ └──_users # Folder containing users uploaded photo 37 | │ └── ... 38 | ├── _routes 39 | │ └── userRoutes.js # REST Endpoints that leads to userController handlers 40 | ├── utils # a set functionalities that can be reused in a lot of project 41 | │ ├── apiFeatures.js 42 | │ ├── appError.js # Extends the Built-in "Error" class. This is the Error object to send to the Global Error handler 43 | │ │── catchAsync.js # a Wrapper for async/await functions to catch async/await erorrs and send them to the Global Error handler 44 | │ └── email.scss # Class to send fake emails in development or real emails in production mode. 45 | ├── _views 46 | │ ├── _emails 47 | │ │ ├──*.pug # Pug files template for emails sending 48 | │ │ └──... 49 | │ └──... 50 | └── config.env # Create this file in the root of the project, it will store the configuration parameters of the project 51 | ``` 52 | 53 | 54 | ## 2. Error Handling 55 | The error handling strategy is important, especially when you have to deal with a lot of promises, async/await in different files. One of the best strategies, is to centralized the handling of errors by having a Global Handling Errors Middleware. 56 | 57 | In addition, we have to distinguish between "operational errors" and "logical errors". The first ones concern the errors that we can predict to occur, eg: request to non existent route (404). The logical errors are the ones we can't predict and generally causes (500) errors. 58 | 59 | A good practise is to have different error handling strategy depending on the environment: *production* or *development*. 60 | 61 | It is also recommended to catch any uncaughtRejection (promises) or uncaughtException (errors). 62 | 63 | --- 64 | 65 | ### A. Global Error Handling `errorcontroller.js` 66 | The global handler error is a middleware with a 4th parameter (error). It could only be one Global Error handler in express and its signature is: `(err, req, res, next) => {...}`. It has 2 different behaviours: `sendErrorDev(err, req, res)` and `sendErrorDev(err, req, res)`, depending on the running environment, one of them is triggered. 67 | 68 | The Global Error handler receives all the `AppError` objects that are created when there is operational errors (created by user) or logical errors. 69 | 70 | ### B. Handling uncaughtException and unhandledRejection 71 | Some errors fire these 2 events: `uncaughtException` and `unhandledRejection`. They are defined in the server (`bin/www`), using `process.on('FIRED_EVENT', callback)`. 72 | 73 | N.B: `process.on('unhandledRejection', callback)` must be on top to be able to catch every error. 74 | 75 | ``` 76 | process.on('uncaughtException', err => { 77 | console.log('UNHANDLED EXCEPTION! 💥💥💥 Shutting down...'); 78 | console.log(err.name, err.message); 79 | process.exit(1); 80 | }); 81 | 82 | process.on('unhandledRejection', err => { 83 | console.log('UNHANDLED REJECTIONS! 💥💥💥 Shutting down...'); 84 | console.log(err.name, err.message); 85 | server.close(() => { 86 | process.exit(1); 87 | }); 88 | }); 89 | ``` 90 | 91 | ### C. Handling async/await errors for requests/response 92 | The middleware: `catchAsync(req, res, next)` is a wrapper around functions that send responses and have async-await operations in them. With this wrapper, we don't need to wrap the function with try & catch blocks. You'll only have to wrap the function with `catchAsync` 93 | 94 | An example will be shown in Section 3.B. 95 | 96 | ```javascript 97 | module.exports = catchAsync = (fn) => { 98 | return (req, res, next) => { 99 | fn(req, res, next).catch(next); 100 | }; 101 | }; 102 | ``` 103 | 104 | 105 | ## 3. Common features 106 | This section concerns the functionalities that can be used simply with other entities/collections than "User". It will focus mainly on 2 files: 107 | 1. `apiFeatures.js` 108 | 2. `handleFactory.js` 109 | 110 | 111 | ### a) apiFeatures.js 112 | --- 113 | APIFeatures is a class that return an object containing a `Mongoose.query` object. It has also 4 different functions which are: `filter()` `sort()` `limiteFields()` `paginate()`. All of them return an instance of the same object which means that we can chain them. 114 | 115 | The power of this class is that it can be used with any Mongoose.Model. 116 | 117 | Exemple of instantiation: 118 | ```javascript 119 | const features = new APIFeatures(Model.find(), req.query) 120 | .filter() 121 | .sort() 122 | .limitFields() 123 | .paginate(); 124 | 125 | const doc = await features.query; 126 | ``` 127 | Here, the `APIFeatures` constructor takes as first argument: `Model.find()` which is a query Mongoose query object. The second parameter is the query object that comes with the request (`req.query`). 128 | 129 | Then, we call: 130 | 1. `filter()`: 131 | 132 | Accept filters as queryString in the request: `domain.com/api/users?age=25&city=New+York`. 133 | The `filter()` function is advanced, and can handle queryString for operations like: 134 | 135 | * Equality `request?field=value`; 136 | * Greater than/Greater than or equal `request?field[gt]=value` `request?field[gte]=`; 137 | * Lesser than/Lesser than or equal `request?field[lt]=value` `request?field[lte]=`; 138 | 139 | 2. `sort()`: 140 | Allow to sort the result in ascending/descending order. 141 | 142 | * Asc: `request?sort=field` 143 | * Desc: `request?sort=-field` 144 | * Multiple sort: `request?sort=field1,field2,-field3` 145 | 146 | 3. `limitFields()`: 147 | Allow to do a `project` on the resulting set. In other words, it allows to select what fields to output in result. 148 | 149 | * Select fields: `request?fields=field1,field2,field3`: Will output a result with only "field1 field2 and field3" in the result. 150 | * Select All except: `request?fields=-field1,-field2...`: Will output a result with all the fields except for "field1 and field2" in the result. 151 | 152 | 3. `pagination()`: 153 | Allow to do a get a subset of the resulting set, depending on the parameters used. It takes as parameter: `page` and `limit`. 154 | 155 | * Select fields: `request?page=2&limit=10`: Will output a result with values starting from the 11th element to the 20th. (Starts at page 2, not 1). 156 | 157 | ### b) handleFactory.js 158 | 159 | ```javascript 160 | exports.getAll = (Model) => catchAsync(async (req, res, next) => {/* code that handles getAll depending on he Model */} 161 | exports.getOne = (Model) => catchAsync(async (req, res, next) => {/* code that handles getOne depending on he Model */} 162 | exports.createOne = (Model) => catchAsync(async (req, res, next) => {/* code that handles createOne depending on he Model */} 163 | exports.updateOne = (Model) => catchAsync(async (req, res, next) => {/* code that handles updateOne depending on he Model */} 164 | exports.deleteOne = (Model) => catchAsync(async (req, res, next) => {/* code that handles deleteOne depending on he Model */} 165 | ``` 166 | --- 167 | The handleFactory is a group of generic functions, that takes as a parameter, the `Model`, which could by any model defined using `Mongoose.model`; in our case, it is the User Model. It helps getting `CRUD operations` done quickly, so we don't have to re-write the same logic for each Model. 168 | 169 | For example, here's what the main CRUD operations in userController looks like: 170 | 171 | ``` 172 | exports.getAllUsers = factory.getAll(User); 173 | exports.getUser = factory.getOne(User); 174 | exports.createUser = factory.createOne(User); 175 | exports.updateUser = factory.updateOne(User); 176 | exports.deleteUser = factory.deleteOne(User); 177 | ``` 178 | 179 | ## 4. Security practises 180 | Security is essential in every web project. Many parts of an application could be vulnerable. This is the reason behind the usage of the coming packages. Please, for more information, visit the used package: 181 | 182 | ```javascript 183 | const helmet = require('helmet'); // Use a lot of others packages, essentially to protect Http headers 184 | const hpp = require('hpp'); // Protect against "http parameters pollution" 185 | const xss = require('xss-clean'); // sanitize user input coming from POST body, GET queries, and url params 186 | const mongoSanitize = require('express-mongo-sanitize'); // sanitize mongodb queries from potential injections 187 | const rateLimit = require('express-rate-limit'); // Basic rate-limiting middleware for Express. Use to limit 188 | // repeated requests to public APIs and/or endpoints such as password reset 189 | ``` 190 | 191 | ## 5. Authenticating users && Authorization 192 | The process of authenticating is performed with a JWT (Json WebToken). 193 | 194 | * It could be used in the `http authorization` header, or: 195 | * Using an HttpOnly secure cookie set with the value of the JWT. 196 | 197 | The `authController.js` is dedicated for all operations that need authentication from user. These operations includes: 198 | 199 | ```javascript 200 | protect /* A middleware that can be run before handler functions that can only be run by logged in users */ 201 | restrictTo('role1', 'role2'...) /* A middleware to restrict access to a resource, only for listed roles */ 202 | signup /* A function to signup user. After signing up, send a welcome email to */ 203 | login /* A function to login user == authenticate user*/ 204 | logout 205 | forgotPassword /*When user hit this route, an email with a Reset token is sent to him*/ 206 | resetPasswortd /* Function used with the resetToken, to changeuser password*/ 207 | updateMyPassword /* Function to update user password */ 208 | ``` 209 | Using `protect` and `restrictTo()` middlewares to guarantee that a resources is accessed either by, a logged in user, or an authorized user. 210 | Exemple: 211 | 212 | ```javascript 213 | /*updateMyPassword can only by done by a logged in user, beause preceeded by "authcontroller.protect" middleware*/ 214 | router.patch('/updateMyPassword', authController.protect, authController.updateMyPassword); 215 | /*getAllUsers can be accessible only for logged in user, and only the "admin" can have access to it*/ 216 | router.get('/', authController.protect, authController.restrictTo('admin'), userController.getAllUsers) 217 | ``` 218 | 219 | ## 6. Installation 220 | 221 | ```bash 222 | git clone https://github.com/Infouzi/Express-Advanced-user-skeleton.git # to pull the project 223 | 224 | npm install #to install dependencies shown in package.json 225 | ``` 226 | 227 | ## 7. Usage 228 | 229 | ```bash 230 | create a config.env file in the root of the project and define theses variables 231 | 232 | NODE_ENV=development 233 | DATABASE=#link-to-your-online-database (eg. create one free in Atlas) 234 | DATABASE_LOCAL=mongodb://localhost:27017/skeleton #skeleton is the name of the databse. You can use yours. 235 | DATABASE_USER=#user-of-distant-database 236 | DATABASE_PASSWORD=#password-of-distant-database 237 | PORT=8000 #or any other port 238 | 239 | JWT_SECRET=#your-super-secret-jwt-password-must-kept-secret 240 | JWT_EXPIRES_IN=90d #read documentation of jwt to know what period to choose 241 | JWT_COOKIE_EXPIRES_IN=90 #cookie validity 242 | 243 | #development mails 244 | #using mailtrap service, it has a free option. It catches the mail you send from the web application (using nodemailer) 245 | #which is destined to the subscribed user. 246 | #Create an account there, then paste your mailtrap username/password. 247 | EMAIL_HOST=smtp.mailtrap.io 248 | EMAIL_PORT=2525 249 | EMAIL_USERNAME=#your-mailtrap-username 250 | EMAIL_PASSWORD=#your-mailtrap-password 251 | 252 | EMAIL_FROM=#define your email if you want 253 | 254 | #production mails 255 | #Using sendgrid service. A little bit more complex to configure than mailtrap. However, it lets you send "Real email" 256 | #to the subscribed user. 257 | SENDGRID_USERNAME=#your-sendgrid-username 258 | SENDGRID_PASSWORD=#your-sendgrid-token-or-password 259 | ``` 260 | 261 | ## 8. Contributing 262 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change or add. 263 | 264 | I'm open to optimize the project as much as it could be. 265 | 266 | ## More information 267 | A special thanks to Udemy instructor [Jonas Schmedtmann](https://www.udemy.com/user/jonasschmedtmann/) for his incredible course on NodeJs. It is really helpful to see the bigger picture of it, and learn the best practises with a great project. 268 | 269 | This project is based essentially on what is shown in his course. Go and check it if you have time, it is amazing. 270 | 271 | ## License 272 | [MIT](https://choosealicense.com/licenses/mit/) --------------------------------------------------------------------------------