├── .editorconfig ├── .gitignore ├── README.md ├── apis └── userEndpoint.js ├── app.js ├── helpers ├── cryptHelpers.js ├── errorHandlers.js ├── jwtHelpers.js ├── loggerHelpers.js ├── mailHelpers.js ├── responseHelpers.js └── stringHelpers.js ├── middlewares └── authMiddleware.js ├── models └── User.js ├── package-lock.json ├── package.json ├── routes └── index.js ├── seeds ├── seeder.js └── users.json ├── services ├── index.js └── users │ ├── authenticate.js │ ├── confirmResetPassword.js │ ├── confirmSignUp.js │ ├── findUserByEmail.js │ ├── findUserCurrent.js │ ├── findUsers.js │ ├── forgotPassword.js │ ├── signUp.js │ └── testAxios.js ├── start.js ├── tests └── .gitkeep ├── variables.env.example └── views └── email ├── confirm-sign-up.pug ├── email-layout.pug ├── password-reset.pug └── styles.css /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [vcbuild.bat] 13 | end_of_line = crlf 14 | 15 | [{lib,src,test}/**.js] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.js] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [src/**.{h,cc}] 24 | indent_style = space 25 | indent_size = 2 26 | 27 | [test/*.py] 28 | indent_style = space 29 | indent_size = 2 30 | 31 | [configure] 32 | indent_style = space 33 | indent_size = 2 34 | 35 | [Makefile] 36 | indent_style = tab 37 | indent_size = 8 38 | 39 | [{deps,tools}/**] 40 | indent_style = ignore 41 | indent_size = ignore 42 | end_of_line = ignore 43 | trim_trailing_whitespace = ignore 44 | charset = ignore 45 | 46 | [{test/fixtures,deps,tools/node_modules,tools/gyp,tools/icu,tools/msvs}/**] 47 | insert_final_newline = false 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # My custom 24 | *.log 25 | .idea 26 | variables.env 27 | /.vscode 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![JavaScript Style Guide](https://cdn.rawgit.com/standard/standard/master/badge.svg)](https://github.com/standard/standard) 2 | 3 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 4 | 5 | # STACK 6 | 7 | - Node.js / Express.js 8 | - MongoDB / Mongoose 9 | - JWT 10 | - [JavaScript Standard Style Guide](https://standardjs.com/) 11 | 12 | --- 13 | # HOW TO RUN 14 | 15 | - Copy `variables.env.sample` to `variables.env` & custom it 16 | 17 | ```bash 18 | npm start 19 | 20 | // or 21 | 22 | npm run watch 23 | ``` 24 | 25 | - Data sample: 26 | ```bash 27 | npm run seed 28 | 29 | npm run seed:delete 30 | 31 | npm run seed:refresh 32 | ``` 33 | 34 | - Lint: 35 | ```bash 36 | npm run lint 37 | ``` 38 | 39 | # ROUTES / API 40 | - Root url: 41 | ```bash 42 | curl -X GET \ 43 | http://localhost:3000/ 44 | ``` 45 | 46 | - API Authenticate: 47 | ```bash 48 | curl -X POST \ 49 | http://localhost:3000/api/authenticate \ 50 | -H 'content-type: application/json' \ 51 | -d '{ 52 | "email": "xinh@mail.com", 53 | "password": "123456" 54 | }' 55 | ``` 56 | 57 | - API Get users: 58 | ```bash 59 | curl -X GET \ 60 | http://localhost:3000/api/users \ 61 | -H 'authorization: Bearer {{YOUR_TOKEN}}' 62 | ``` 63 | 64 | - API Sign up: 65 | ```bash 66 | curl -X POST \ 67 | http://localhost:3000/api/sign-up \ 68 | -H 'content-type: application/json' \ 69 | -d '{ 70 | "name": "Mail1", 71 | "email": "mail1@mail.com", 72 | "password": "123456" 73 | }' 74 | ``` 75 | 76 | - API Confirm sign up: 77 | ```bash 78 | curl -X GET \ 79 | 'http://localhost:3000/api/confirm-sign-up?token={{YOUR_TOKEN}}' 80 | ``` 81 | 82 | - API Test axios: 83 | ```bash 84 | curl -X GET \ 85 | http://localhost:3000/api/test-axios \ 86 | -H 'authorization: Bearer {{YOUR_TOKEN}}' 87 | ``` 88 | 89 | - API Forgot password: 90 | ```bash 91 | curl -X POST \ 92 | http://localhost:3000/api/forgot-password \ 93 | -H 'content-type: application/json' \ 94 | -d '{ 95 | "email": "xinh@mail.com" 96 | }' 97 | ``` 98 | 99 | - API Confirm reset password: 100 | ```bash 101 | curl -X GET \ 102 | 'http://localhost:3000/api/confirm-resest-password?token={{YOUR_TOKEN}}' 103 | ``` 104 | 105 | - API Get user current: 106 | ```bash 107 | curl -X GET \ 108 | http://localhost:3000/api/get-user-current \ 109 | -H 'authorization: Bearer {{YOUR_TOKEN}}' 110 | ``` 111 | 112 | --- 113 | # REFERENCES 114 | -------------------------------------------------------------------------------- /apis/userEndpoint.js: -------------------------------------------------------------------------------- 1 | const services = require('../services') 2 | const { SuccessResponse } = require('../helpers/responseHelpers') 3 | 4 | exports.getUsers = async (req, res) => { 5 | const users = await services.users.findUsers() 6 | 7 | return res.json( 8 | new SuccessResponse.Builder() 9 | .withContent(users) 10 | .build() 11 | ) 12 | } 13 | 14 | exports.postAuthenticate = async (req, res) => { 15 | // Get input data 16 | let email = req.body.email 17 | let password = req.body.password 18 | 19 | let token = await services.users.authenticate(email, password) 20 | 21 | return res.json( 22 | new SuccessResponse.Builder() 23 | .withContent(token) 24 | ) 25 | } 26 | 27 | exports.postSignUp = async (req, res) => { 28 | // Get input data 29 | let name = req.body.name 30 | let email = req.body.email 31 | let password = req.body.password 32 | 33 | const user = await services.users.signUp(name, email, password, req.headers.host) 34 | 35 | return res.json( 36 | new SuccessResponse.Builder() 37 | .withContent(user) 38 | .build() 39 | ) 40 | } 41 | 42 | exports.getConfirmSignUp = async (req, res) => { 43 | // Get input data 44 | let token = req.query.token 45 | 46 | const user = await services.users.confirmSignUp(token) 47 | 48 | return res.json( 49 | new SuccessResponse.Builder() 50 | .withContent(user) 51 | .build() 52 | ) 53 | } 54 | 55 | exports.getTestAxios = async (req, res) => { 56 | const data = await services.users.testAxios() 57 | 58 | return res.json( 59 | new SuccessResponse.Builder() 60 | .withContent(data) 61 | .build() 62 | ) 63 | } 64 | 65 | exports.postForgotPassword = async (req, res) => { 66 | // Get input data 67 | let email = req.body.email 68 | 69 | const user = await services.users.forgotPassword(email, req.headers.host) 70 | 71 | return res.json( 72 | new SuccessResponse.Builder() 73 | .withContent(user) 74 | .withMessage('You have been emailed a password reset link.') 75 | .build() 76 | ) 77 | } 78 | 79 | exports.getConfirmResetPassword = async (req, res) => { 80 | // Get input data 81 | let token = req.query.token 82 | 83 | const user = await services.users.confirmResetPassword(token) 84 | 85 | return res.json( 86 | new SuccessResponse.Builder() 87 | .withContent(user) 88 | .withMessage(`Your password is: `) 89 | .build() 90 | ) 91 | } 92 | 93 | exports.getUserCurrent = async (req, res) => { 94 | // Get token from request 95 | // const token = req.decoded.payload.email 96 | 97 | // const user = await services.users.findUserCurrent(token) 98 | 99 | // return res.json( 100 | // new SuccessResponse.Builder() 101 | // .withContent(user) 102 | // .build() 103 | // ) 104 | 105 | return res.json( 106 | new SuccessResponse.Builder() 107 | .withContent(req.userCurrent) 108 | .build() 109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const bodyParser = require('body-parser') 3 | const morgan = require('morgan') 4 | const cors = require('cors') 5 | 6 | const routes = require('./routes/index') 7 | 8 | // Create our Express app 9 | const app = express() 10 | 11 | // Enable All CORS Requests 12 | app.use(cors()) 13 | 14 | // Takes the raw requests and turns them into usable properties on req.body 15 | app.use(bodyParser.json()) 16 | app.use(bodyParser.urlencoded({ extended: true })) 17 | 18 | // Use morgan to log requests to the console 19 | app.use(morgan('dev')) 20 | 21 | // After allllll that above middleware, we finally handle our own routes! 22 | app.use('/', routes) 23 | 24 | // Done! we export it so we can start the site in start.js 25 | module.exports = app 26 | -------------------------------------------------------------------------------- /helpers/cryptHelpers.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | exports.md5 = function (str) { 4 | var md5sum = crypto.createHash('md5') 5 | md5sum.update(str) 6 | str = md5sum.digest('hex') 7 | return str 8 | } 9 | 10 | exports.encryptAes = function (str, secret) { 11 | var cipher = crypto.createCipher('aes192', secret) 12 | var enc = cipher.update(str, 'utf8', 'hex') 13 | enc += cipher.final('hex') 14 | return enc 15 | } 16 | 17 | exports.decryptAes = function (str, secret) { 18 | var decipher = crypto.createDecipher('aes192', secret) 19 | var dec = decipher.update(str, 'hex', 'utf8') 20 | dec += decipher.final('utf8') 21 | return dec 22 | } 23 | -------------------------------------------------------------------------------- /helpers/errorHandlers.js: -------------------------------------------------------------------------------- 1 | /* 2 | Catch Errors Handler 3 | 4 | With async/await, you need some way to catch errors 5 | Instead of using try{} catch(e) {} in each controller, we wrap the function in 6 | catchErrors(), catch and errors they throw, and pass it along to our express middleware with next() 7 | */ 8 | 9 | exports.catchErrors = (fn) => { 10 | return (req, res, next) => { 11 | return fn(req, res, next).catch(next) 12 | } 13 | } 14 | 15 | /* 16 | Not Found Error Handler 17 | If we hit a route that is not found, we mark it as 404 and pass it along to the next error handler to display 18 | */ 19 | exports.notFound = (req, res, next) => { 20 | const err = new Error('Not Found') 21 | err.status = 404 22 | next(err) 23 | } 24 | 25 | /* 26 | MongoDB Validation Error Handler 27 | Detect if there are mongodb validation errors that we can nicely show via flash messages 28 | */ 29 | 30 | exports.flashValidationErrors = (err, req, res, next) => { 31 | if (!err.errors) return next(err) 32 | // validation errors look like 33 | const errorKeys = Object.keys(err.errors) 34 | errorKeys.forEach(key => req.flash('error', err.errors[key].message)) 35 | res.redirect('back') 36 | } 37 | 38 | /* 39 | Development Error Hanlder 40 | In development we show good error messages so if we hit a syntax error or any other previously un-handled error, we can show good info on what happened 41 | */ 42 | exports.developmentErrors = (err, req, res, next) => { 43 | err.stack = err.stack || '' 44 | const errorDetails = { 45 | message: err.message, 46 | status: err.status, 47 | stackHighlighted: err.stack.replace(/[a-z_-\d]+.js:\d+:\d+/gi, '$&') 48 | } 49 | res.status(err.status || 500) 50 | res.format({ 51 | // Based on the `Accept` http header 52 | 'text/html': () => { 53 | res.render('error', errorDetails) 54 | }, // Form Submit, Reload the page 55 | 'application/json': () => res.json(errorDetails) // Ajax call, send JSON back 56 | }) 57 | } 58 | 59 | /* 60 | Production Error Hanlder 61 | No stacktraces are leaked to user 62 | */ 63 | exports.productionErrors = (err, req, res, next) => { 64 | res.status(err.status || 500) 65 | res.render('error', { 66 | message: err.message, 67 | error: {} 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /helpers/jwtHelpers.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | 3 | exports.encode = (payload) => { 4 | let token = jwt.sign({ payload }, process.env.JWT_SECRET, { expiresIn: '1h' }) 5 | return token 6 | } 7 | 8 | exports.decode = async (token) => { 9 | let decoded = await jwt.verify(token, process.env.JWT_SECRET) 10 | return decoded 11 | } 12 | -------------------------------------------------------------------------------- /helpers/loggerHelpers.js: -------------------------------------------------------------------------------- 1 | const { createLogger, format, transports } = require('winston') 2 | const { combine, timestamp, label, printf, colorize } = format 3 | 4 | const myFormat = printf(info => { 5 | return `${info.timestamp} [${info.label}] ${info.level}: ${info.message}` 6 | }) 7 | 8 | const logger = createLogger({ 9 | level: process.env.LOG_LEVEL, 10 | format: combine( 11 | label({ label: process.env.APP_NAME }), 12 | timestamp(), 13 | myFormat 14 | ), 15 | transports: [ 16 | // 17 | // - Write to all logs with level `info` and below to `combined.log` 18 | // - Write all logs error (and below) to `error.log`. 19 | // 20 | new transports.File({ filename: 'error.log', level: 'error' }), 21 | new transports.File({ filename: 'combined.log' }) 22 | ] 23 | }) 24 | 25 | // 26 | // If we're not in production then log to the `console` with the format: 27 | // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` 28 | // 29 | if (process.env.NODE_ENV !== 'production') { 30 | logger.add(new transports.Console({ 31 | format: combine( 32 | label({ label: process.env.APP_NAME }), 33 | colorize({ property: 'label' }), // colorize({ all: true }), 34 | timestamp(), 35 | myFormat 36 | ), 37 | colorize: true 38 | })) 39 | } 40 | 41 | module.exports = logger 42 | -------------------------------------------------------------------------------- /helpers/mailHelpers.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer') 2 | const pug = require('pug') 3 | const juice = require('juice') 4 | const htmlToText = require('html-to-text') 5 | const promisify = require('es6-promisify') 6 | 7 | const transport = nodemailer.createTransport({ 8 | host: process.env.MAIL_HOST, 9 | port: process.env.MAIL_PORT, 10 | auth: { 11 | user: process.env.MAIL_USER, 12 | pass: process.env.MAIL_PASS 13 | } 14 | }) 15 | 16 | const generateHTML = (filename, options = {}) => { 17 | const html = pug.renderFile(`${__dirname}/../views/email/${filename}.pug`, options) 18 | const inlined = juice(html) 19 | return inlined 20 | } 21 | 22 | exports.send = async (options) => { 23 | const html = generateHTML(options.filename, options) 24 | const text = htmlToText.fromString(html) 25 | 26 | const mailOptions = { 27 | from: `Xinh Nguyen `, 28 | to: options.user.email, 29 | subject: options.subject, 30 | html, 31 | text 32 | } 33 | const sendMail = promisify(transport.sendMail, transport) 34 | return sendMail(mailOptions) 35 | } 36 | -------------------------------------------------------------------------------- /helpers/responseHelpers.js: -------------------------------------------------------------------------------- 1 | exports.SuccessResponse = class SuccessResponse { 2 | constructor (build) { 3 | this.success = true 4 | this.content = build.content 5 | this.message = build.message || '' 6 | } 7 | static get Builder () { 8 | class Builder { 9 | withContent (content) { 10 | this.content = content 11 | return this 12 | } 13 | withMessage (message) { 14 | this.message = message 15 | return this 16 | } 17 | build () { 18 | return new SuccessResponse(this) 19 | } 20 | } 21 | return Builder 22 | } 23 | } 24 | 25 | exports.FailResponse = class FailResponse { 26 | constructor (build) { 27 | this.success = false 28 | this.content = build.content 29 | this.message = build.message || '' 30 | } 31 | static get Builder () { 32 | class Builder { 33 | withContent (content) { 34 | this.content = content 35 | return this 36 | } 37 | withMessage (message) { 38 | this.message = message 39 | return this 40 | } 41 | build () { 42 | return new FailResponse(this) 43 | } 44 | } 45 | return Builder 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /helpers/stringHelpers.js: -------------------------------------------------------------------------------- 1 | exports.randomPassword = () => { 2 | return Math.random() // Generate random number, eg: 0.123456 3 | .toString(36) // Convert to base-36 : "0.4fzyo82mvyr" 4 | .slice(-8) // Cut off last 8 characters : "yo82mvyr" 5 | } 6 | -------------------------------------------------------------------------------- /middlewares/authMiddleware.js: -------------------------------------------------------------------------------- 1 | const { FailResponse } = require('../helpers/responseHelpers') 2 | const logger = require('../helpers/loggerHelpers') 3 | const jwtHelpers = require('../helpers/jwtHelpers') 4 | 5 | const services = require('../services') 6 | 7 | exports.getAuthorize = async (req, res, next) => { 8 | // Check header or url parameters or post parameters for token 9 | const headerAuthorize = req.body.token || req.query.token || req.headers['x-access-token'] || req.headers.authorization 10 | 11 | // Check exist token 12 | if (!headerAuthorize) { 13 | logger.warn('Header Authorize Not Found') 14 | return res.json( 15 | new FailResponse.Builder() 16 | .withMessage('Header Authorize Not Found') 17 | .build() 18 | ) 19 | } 20 | 21 | // Get token 22 | const token = headerAuthorize.replace(process.env.JWT_TOKEN_TYPE, '').trim() 23 | 24 | // Decode token 25 | // Verifies secret and checks exp 26 | try { 27 | const decoded = await jwtHelpers.decode(token, process.env.JWT_SECRET) 28 | // Save decoded to request 29 | req.decoded = decoded 30 | // Save user current to request 31 | const email = decoded.payload.email 32 | req.userCurrent = await services.users.findUserByEmail(email) 33 | return next() 34 | } catch (err) { 35 | logger.error(`Token Decode Error ${err}`) 36 | return res.json( 37 | new FailResponse.Builder() 38 | .withContent(err) 39 | .build() 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const Schema = mongoose.Schema 3 | mongoose.Promise = global.Promise 4 | const md5 = require('md5') 5 | const validator = require('validator') 6 | const bcrypt = require('bcrypt') 7 | 8 | const userSchema = new Schema({ 9 | email: { 10 | type: String, 11 | unique: true, 12 | lowercase: true, 13 | trim: true, 14 | validate: { 15 | isAsync: true, 16 | validator: validator.isEmail, 17 | message: 'Invalid Email Address' 18 | }, 19 | required: 'Please Supply an email address' 20 | }, 21 | password: { 22 | type: String, 23 | required: 'Please supply a password', 24 | trim: true 25 | }, 26 | name: { 27 | type: String, 28 | required: 'Please supply a name', 29 | trim: true 30 | }, 31 | resetPasswordToken: String, 32 | resetPasswordExpires: Date, 33 | enable: { type: Boolean, default: false } 34 | }, { timestamps: true, toJSON: { virtuals: true } }) 35 | 36 | userSchema.virtual('gravatar').get(() => { 37 | const hash = md5(this.email) 38 | return `https://gravatar.com/avatar/${hash}?s=200` 39 | }) 40 | 41 | userSchema.pre('save', async function (next) { 42 | try { 43 | if (!this.isModified('password')) { 44 | // Skip it & stop this function from running 45 | return next() 46 | } 47 | 48 | // Generate a salt 49 | const salt = await bcrypt.genSalt(Number(process.env.SALT_ROUNDS)) 50 | 51 | // Hash the password along with our new salt 52 | const hash = await bcrypt.hash(this.password, salt) 53 | 54 | // Override the cleartext password with the hashed one 55 | this.password = hash 56 | 57 | return next() 58 | } catch (e) { 59 | return next(e) 60 | } 61 | }) 62 | 63 | module.exports = mongoose.model('User', userSchema) 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expressjs-starter-kit", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "start.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node ./start.js", 9 | "watch": "nodemon ./start.js --ignore public/", 10 | "seed": "node ./seeds/seeder.js", 11 | "seed:delete": "node ./seeds/seeder.js --delete", 12 | "seed:refresh": "npm run seed:delete && npm run seed", 13 | "lint": "standard" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "axios": "^0.17.1", 19 | "bcrypt": "^1.0.3", 20 | "body-parser": "^1.18.2", 21 | "cors": "^2.8.4", 22 | "dotenv": "^4.0.0", 23 | "es6-promisify": "^5.0.0", 24 | "express": "^4.16.2", 25 | "html-to-text": "^3.3.0", 26 | "jsonwebtoken": "^8.1.0", 27 | "juice": "^4.2.2", 28 | "md5": "^2.2.1", 29 | "mongoose": "^4.13.9", 30 | "morgan": "^1.9.0", 31 | "nodemailer": "^4.4.1", 32 | "pug": "^2.0.0-rc.4", 33 | "validator": "^9.2.0", 34 | "winston": "^3.0.0-rc1" 35 | }, 36 | "devDependencies": { 37 | "nodemon": "^1.14.10", 38 | "standard": "^10.0.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | 4 | const { catchErrors } = require('../helpers/errorHandlers') 5 | const userEndpoint = require('../apis/userEndpoint') 6 | const { getAuthorize } = require('../middlewares/authMiddleware') 7 | 8 | // Unprotected routes 9 | router.get('/', (req, res) => res.json({ msg: process.env.APP_NAME })) 10 | router.post('/api/authenticate', catchErrors(userEndpoint.postAuthenticate)) 11 | router.post('/api/sign-up', catchErrors(userEndpoint.postSignUp)) 12 | router.get('/api/confirm-sign-up', catchErrors(userEndpoint.getConfirmSignUp)) 13 | router.post('/api/forgot-password', catchErrors(userEndpoint.postForgotPassword)) 14 | router.get('/api/confirm-reset-password', catchErrors(userEndpoint.getConfirmResetPassword)) 15 | 16 | // Middlewares 17 | router.use(getAuthorize) 18 | 19 | // Protected routes 20 | router.get('/api/users', catchErrors(userEndpoint.getUsers)) 21 | router.get('/api/get-user-current', catchErrors(userEndpoint.getUserCurrent)) 22 | router.get('/api/test-axios', catchErrors(userEndpoint.getTestAxios)) 23 | 24 | module.exports = router 25 | -------------------------------------------------------------------------------- /seeds/seeder.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | require('dotenv').config({ path: path.join(__dirname, '../variables.env') }) 4 | const fs = require('fs') 5 | 6 | const mongoose = require('mongoose') 7 | mongoose.connect(process.env.DATABASE, { useMongoClient: true }) 8 | mongoose.Promise = global.Promise // Tell Mongoose to use ES6 promises 9 | 10 | // Import all of our models - they need to be imported only once 11 | const User = require('../models/User') 12 | 13 | // Password: 123456 14 | const users = JSON.parse(fs.readFileSync(path.join(__dirname, '/users.json'), 'utf-8')) 15 | 16 | const deleteData = async () => { 17 | console.log('😢😢 Goodbye Data...') 18 | await User.remove() 19 | console.log('Data Deleted. To load sample data, run\n\n\t npm run seed\n\n') 20 | process.exit() 21 | } 22 | 23 | const loadData = async () => { 24 | try { 25 | await User.insertMany(users) 26 | console.log('👍👍👍👍👍👍👍👍 Done!') 27 | process.exit() 28 | } catch (e) { 29 | console.log('\n👎👎👎👎👎👎👎👎 Error! The Error info is below but if you are importing sample data make sure to drop the existing database first with.\n\n\t npm run seed:delete\n\n\n') 30 | console.log(e) 31 | process.exit() 32 | } 33 | } 34 | 35 | if (process.argv.includes('--delete')) { 36 | deleteData() 37 | } else { 38 | loadData() 39 | } 40 | -------------------------------------------------------------------------------- /seeds/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "58c039018060197ca0b52d4c", 4 | "salt": "1997f002c3db624f17acdcc8a1a21be1708916f0b21360e7b460f20dae2a179a", 5 | "hash": "c26a834cacea8486c5e25b046c219ea11df633320ecf78834ee72d6be12cfb06a2ce224442cee16f0d299931dc8c1694a7416c825fef4e95fcb5a7aa28cf4a98bc0111eb00fd7623e7ab12139327b66558dd97606df3f2848de21e878bc3226016b6faaef7e04cbdcd15ea23835dfc8f0b603ae0a74af7daecffcb6ff30d734c57cd35579a8535d3bc65cc0c86befcde4a3b8bb5f1fce31b709f58202507ee1809c61b2600122ca077988c229414baee51d8993a0b0389d60d154d6b64285cd56ff4ace497727ea1c48917feef981ea2115520c9fe3952e03e7c779eebd0a58ad94c62d321ebb22b11f6e7d414e3c61e5a4aebb6d40c304e6f2875181483716f5ad5b8e525dfbd7bda537b429dfd43881ea9ff60c5e405268180d4f2b442cca51ac1ee303f8a94d2622ec13e4a11e04a4565187569bc27e32dc8868677f2f5a8cecbef7a49e6708ba264d1c1335a4b8fe45fdb2912b59441f438ef762d7cf7f0f03ea76290fc701858817413cc60eae20ec6c62d787eaa6d2a9917213f3d832793f3651450969ed32ccf888cb6dcffa50d3d210eeb592319392c1a0523d942dfcd06f1be714cecf48b01fc7f3c2ac6f3f7a87d8db22c418a8b4e18fdfb129c7ee0e4912b7d343d6d30d3a59c868c047f6ddb88de5317c161c88217cd1788895482834596e753bed0d452969866ca83a891103f216797e001edff058e9d4ece97", 6 | "email": "xinh@mail.com", 7 | "password": "$2a$10$eyWiOKpRT6WTZiMtQL1HZuknBf1Jo4nploL/cR/TgwOhKpAFpZW8O", 8 | "name": "Xinh Nguyen", 9 | "enable": true 10 | }, 11 | { 12 | "_id": "58c03ada8060197ca0b52d52", 13 | "salt": "3a8479b05be4c4f1f62f484e02081be7662ee46b5de17cb8721d043fca364ca5", 14 | "hash": "3355b0acc7b0cebd66675843ae7d56048ea093432c6ca096858850d18a443bcb3fc87d5b4df911f3b73e22bc672e760284013e581ecd98cbf4223441c6d708a1266cc188753f167caafe9b06f2fa236ece8b5320f3f002167d781698f12da373a48e465a44b008b84cf92142bc4f35818d6e86a45a0a17c43ff6f51d0eea4cd1cb59a3cc9d8ac44f5feec3a054993dacc6f83d85d324ee50eb7cfa1e01f878394e350a11ee9a3620c5280c192ca6b50a288695e5676c6dba828162b13b1a3d6a8b91bc82799043a3127c8fc34300987ca73dbec36721d1b78fa35f36c67fabdaaecc437e34cedeffdf41ae61e1b5b12d326b9ecb9bbc09b24ac9aa89e5ca0297ad2ee21f448c38324bc7795563a41d9661c63975ecfd4734496e5bf93376691e6683e8a58a272800957ab6606372bdfee5e31ce551b83ac7db6d422a72394747ef836d7a02ecb9502001d82254663fe5f42d040d653730d366f168429f70907b107fab12beca4d0b273130a2aeae1b652001e86b5bed85355721cb5d355bd4200369677b8b62761ac2063323270b33e45827d51f7b78e8f2ac7d9c701e5528087452692dba57d4d81d65cc8ad1d898fdbd108fdb3be148869ff737f91b5ebc1b68595504c9fd8c2b38a5809f76e470689dd1e5b27803503192c61e6cb135b9b93651bb06b369ebbf7585356255d73c74cabe9ae4f84f98c388b729399340106a", 15 | "email": "debbie@example.com", 16 | "password": "$2a$10$eyWiOKpRT6WTZiMtQL1HZuknBf1Jo4nploL/cR/TgwOhKpAFpZW8O", 17 | "name": "Debbie Downer", 18 | "enable": true 19 | }, 20 | { 21 | "_id": "58c08bbed1d97c276fd56ce3", 22 | "salt": "e2e70759d59ad47beb07170abf44a7f24a03c41b28c579b79e0a9e75d3648aef", 23 | "hash": "40d93d6c3ef8fb2e4775867de18532f9d6a8509a5bb85a1e20c418dbea4083a94e258b4340572a5e6fc18fac32f98362137fb081492db9e0f12c7c3b8c87090c77a12894046fecccb83714e78fe4a7b3e993b90e4d8816fbf05306d5e7ca2b0209833831e16451a928fcb3ad23b1a0f8ef029d8813b6dd00068f3e20df58e5a32c85f2cc85b4a9ac0b1bcd5e2bbab489d7d5649c26d75e70d3fcb01347de7f0e2f41dc53d28dec7a0a9fd4e05b2e499a6f646dcad99c80adfb7c4d35ce607ab4450c24aa08faf4f43b5e303410bc7cf6cf968bb15d474cf7f6edf9bd66daac6340c839d5675a0b8137d1b35c2888e0a82c0d5643c12b69b01e2cbb19f82daffffc80ceb28799711f181e37b232d29795772bde87b9eb099e22f5afcd3ec6c76fef4f55d1ba17f5a15fea2b52b58fe465e5733b1136a3ee7b45bbae79957b7c0c2f81922dac35b227fe934ae92aac413c29d79cc1551dd95c9144d058449d1ed3d24fdcc83f06ba8e25f21b5057216a1291459991c9914378b38da30064d228411ae7886199101ff1bbfd348e691f963f266892252c3a14714650b3c92cb9f8970186950fd0f7a5a71c2f679cc577c81e4b668aa8a76a282e2cdf55a38bf9aa225c330236fb279c8a6f094179876ddaa574e15f08abbfeb2138cb3fae1753d3ab0206bb198f5315700a61cb83ef5be559884aa6132fe1161af8b3873546b5acfa", 24 | "email": "beau@example.com", 25 | "password": "$2a$10$eyWiOKpRT6WTZiMtQL1HZuknBf1Jo4nploL/cR/TgwOhKpAFpZW8O", 26 | "name": "Beau", 27 | "enable": true 28 | } 29 | ] 30 | -------------------------------------------------------------------------------- /services/index.js: -------------------------------------------------------------------------------- 1 | const { authenticate } = require('./users/authenticate') 2 | const { confirmResetPassword } = require('./users/confirmResetPassword') 3 | const { confirmSignUp } = require('./users/confirmSignUp') 4 | const { findUserCurrent } = require('./users/findUserCurrent') 5 | const { findUsers } = require('./users/findUsers') 6 | const { forgotPassword } = require('./users/forgotPassword') 7 | const { signUp } = require('./users/signUp') 8 | const { testAxios } = require('./users/testAxios') 9 | const { findUserByEmail } = require('./users/findUserByEmail') 10 | 11 | const services = { 12 | users: { 13 | authenticate, 14 | confirmResetPassword, 15 | confirmSignUp, 16 | findUserCurrent, 17 | findUsers, 18 | forgotPassword, 19 | signUp, 20 | testAxios, 21 | findUserByEmail 22 | } 23 | } 24 | 25 | module.exports = services 26 | -------------------------------------------------------------------------------- /services/users/authenticate.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const bcrypt = require('bcrypt') 3 | 4 | const User = mongoose.model('User') 5 | const logger = require('../../helpers/loggerHelpers') 6 | const jwtHelpers = require('../../helpers/jwtHelpers') 7 | 8 | exports.authenticate = async (email, password) => { 9 | // Find the user 10 | const user = await User.findOne({ email, enable: true }) 11 | 12 | // Check if user exist 13 | if (!user) { 14 | logger.warn('Authentication failed. User not found.') 15 | throw new Error('Authentication failed. User not found.') 16 | } 17 | 18 | // Check if password matches 19 | if (!await bcrypt.compare(password, user.password)) { 20 | logger.warn('Authentication failed. Wrong password.') 21 | throw new Error('Authentication failed. Wrong password.') 22 | } 23 | 24 | // Create a token with only our given payload 25 | let token = jwtHelpers.encode({ email }, process.env.JWT_SECRET, { expiresIn: '1h' }) 26 | logger.info(`Auth token created: ${token}`) 27 | 28 | // Return the information including token as JSON 29 | return { token: `${process.env.JWT_TOKEN_TYPE} ${token}` } 30 | } 31 | -------------------------------------------------------------------------------- /services/users/confirmResetPassword.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const User = mongoose.model('User') 4 | const { randomPassword } = require('../../helpers/stringHelpers') 5 | const logger = require('../../helpers/loggerHelpers') 6 | 7 | exports.confirmResetPassword = async (token) => { 8 | // See if a user with that token exists 9 | const user = await User.findOne({ 10 | resetPasswordToken: token, 11 | resetPasswordExpires: { $gt: Date.now() } 12 | }) 13 | if (!user) { 14 | throw new Error('Password reset is invalid or has expired') 15 | } 16 | 17 | // Generate new password 18 | let password = randomPassword() 19 | logger.info(`Password was generated: ${password}`) 20 | user.password = password 21 | user.resetPasswordToken = undefined 22 | user.resetPasswordExpires = undefined 23 | await user.save() 24 | 25 | return user 26 | } 27 | -------------------------------------------------------------------------------- /services/users/confirmSignUp.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const User = mongoose.model('User') 4 | const logger = require('../../helpers/loggerHelpers') 5 | const jwtHelpers = require('../../helpers/jwtHelpers') 6 | 7 | exports.confirmSignUp = async (token) => { 8 | // Check exist token 9 | if (!token) { 10 | throw new Error('Token Not Found') 11 | } 12 | 13 | // Decode token 14 | // Verifies secret and checks exp 15 | try { 16 | let decoded = await jwtHelpers.decode(token, process.env.JWT_SECRET) 17 | let email = decoded.payload.email 18 | 19 | // Enable user 20 | let user = await User.findOne({ email }) 21 | user.enable = true 22 | await user.save() 23 | 24 | // Create response 25 | return user 26 | } catch (err) { 27 | logger.error(`Token Decode Error ${err}`) 28 | throw new Error(`Token Decode Error ${err}`) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /services/users/findUserByEmail.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const User = mongoose.model('User') 4 | 5 | exports.findUserByEmail = async (email) => { 6 | const user = await User.findOne({ email }) 7 | return user 8 | } 9 | -------------------------------------------------------------------------------- /services/users/findUserCurrent.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const jwtHelpers = require('../../helpers/jwtHelpers') 3 | 4 | const User = mongoose.model('User') 5 | 6 | exports.findUserCurrent = async (token) => { 7 | // Decode token 8 | try { 9 | let decoded = await jwtHelpers.decode(token, process.env.JWT_SECRET) 10 | let email = decoded.payload.email 11 | // Find user 12 | let user = await User.findOne({ email }) 13 | return user 14 | } catch (err) { 15 | throw new Error(err) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /services/users/findUsers.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const User = mongoose.model('User') 4 | 5 | exports.findUsers = async () => { 6 | const users = await User.find() 7 | return users 8 | } 9 | -------------------------------------------------------------------------------- /services/users/forgotPassword.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const User = mongoose.model('User') 4 | const mailHelpers = require('../../helpers/mailHelpers') 5 | const jwtHelpers = require('../../helpers/jwtHelpers') 6 | 7 | exports.forgotPassword = async (email, host) => { 8 | // 1. See if a user with that email exists 9 | const user = await User.findOne({ email }) 10 | if (!user) { 11 | throw new Error('No account with that email exists.') 12 | } 13 | // 2. Set reset tokens and expiry on their account 14 | user.resetPasswordToken = jwtHelpers.encode({ email }, process.env.JWT_SECRET, { expiresIn: '1h' }) 15 | user.resetPasswordExpires = Date.now() + 3600000 // 1 hour from now 16 | await user.save() 17 | // 3. Send them an email with the token 18 | const resetURL = `http://${host}/api/confirm-reset-password?token=${user.resetPasswordToken}` 19 | await mailHelpers.send({ 20 | user, 21 | filename: 'password-reset', 22 | subject: 'Password Reset', 23 | resetURL 24 | }) 25 | 26 | return user 27 | } 28 | -------------------------------------------------------------------------------- /services/users/signUp.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const User = mongoose.model('User') 4 | const mailHelpers = require('../../helpers/mailHelpers') 5 | const jwtHelpers = require('../../helpers/jwtHelpers') 6 | 7 | exports.signUp = async (name, email, password, host) => { 8 | // Save the user 9 | const user = new User({ name, email, password }) 10 | await user.save() 11 | 12 | // Send them an email with the token 13 | const tokenConfirm = jwtHelpers.encode({ email }, process.env.JWT_SECRET, { expiresIn: '15d' }) 14 | const resetURL = `http://${host}/api/confirm-sign-up?token=${tokenConfirm}` 15 | await mailHelpers.send({ 16 | user, 17 | filename: 'confirm-sign-up', 18 | subject: 'Confirm Sign Up', 19 | resetURL 20 | }) 21 | 22 | return user 23 | } 24 | -------------------------------------------------------------------------------- /services/users/testAxios.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | 3 | exports.testAxios = async () => { 4 | // Grab some data over an Ajax request 5 | const xinh = await axios('https://api.github.com/users/nguyentrucxinh') 6 | 7 | // Many requests should be concurrent - don't slow things down! 8 | // Fire off two requests and save their promises 9 | const userPromise = axios('https://randomuser.me/api/') 10 | const namePromise = axios('https://uinames.com/api/') 11 | // Await all three promises to come back and destructure the result into their own variables 12 | const [user, name] = await Promise.all([userPromise, namePromise]) 13 | 14 | return { xinh: xinh.data, user: user.data, name: name.data } 15 | } 16 | -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | // Import environmental variables from our variables.env file 6 | require('dotenv').config({ path: 'variables.env' }) 7 | 8 | // Connect to our Database and handle an bad connections 9 | mongoose.connect(process.env.DATABASE, { useMongoClient: true }) 10 | mongoose.Promise = global.Promise // Tell Mongoose to use ES6 promises 11 | mongoose.connection.on('error', (err) => { 12 | console.error(`-> ${err.message}`) 13 | }) 14 | 15 | // READY?! Let's go! 16 | 17 | // Import all of our models 18 | const modelsPath = path.join(__dirname, 'models') 19 | fs.readdirSync(modelsPath).forEach(function (file) { 20 | require(path.join(modelsPath, file)) 21 | }) 22 | 23 | // Start our app! 24 | const app = require('./app') 25 | app.set('port', process.env.PORT || 7777) 26 | const server = app.listen(app.get('port'), () => { 27 | console.log(`Express running → PORT ${server.address().port}`) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntxinh/expressjs-starter-kit/3be41ee32bdc7e639037f2be56853aac01721c75/tests/.gitkeep -------------------------------------------------------------------------------- /variables.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | DATABASE= 3 | MAIL_USER= 4 | MAIL_PASS= 5 | MAIL_HOST=mailtrap.io 6 | MAIL_PORT=2525 7 | PORT=3000 8 | JWT_SECRET=secret 9 | JWT_TOKEN_TYPE=Bearer 10 | SALT_ROUNDS=10 11 | LOG_LEVEL=info 12 | APP_NAME=Express.js Starter Kit 13 | -------------------------------------------------------------------------------- /views/email/confirm-sign-up.pug: -------------------------------------------------------------------------------- 1 | extends email-layout 2 | 3 | block content 4 | h2 Confirm Sign Up 5 | p Hello! 6 | p Thank you for registering at my website. Your account is created and must be activated before you can use it. 7 | p To activate the account click on following link: 8 | +button(resetURL, 'Confirm Sign Up →') 9 | p If you can't click the above button please visit #{resetURL} 10 | p After activation you may login to my website using your username and password. 11 | br 12 | p If you didn't request this email, please ignore it. 13 | -------------------------------------------------------------------------------- /views/email/email-layout.pug: -------------------------------------------------------------------------------- 1 | //- Handy mixin for making buttons 2 | mixin button(url, text) 3 | table.btn.btn-primary(border='0', cellpadding='0', cellspacing='0') 4 | tbody 5 | tr 6 | td(align='left') 7 | table(border='0', cellpadding='0', cellspacing='0') 8 | tbody: tr: td: a(href=url, target='_blank')= text 9 | 10 | //- The Email 11 | doctype html 12 | html 13 | head 14 | meta(name='viewport', content='width=device-width') 15 | meta(http-equiv='Content-Type', content='text/html; charset=UTF-8') 16 | style 17 | include styles.css 18 | body 19 | table.body(border='0', cellpadding='0', cellspacing='0') 20 | tr 21 | td   22 | td.container 23 | .content 24 | table.main 25 | tr 26 | td.wrapper 27 | table(border='0', cellpadding='0', cellspacing='0') 28 | tr 29 | td 30 | block content 31 | -------------------------------------------------------------------------------- /views/email/password-reset.pug: -------------------------------------------------------------------------------- 1 | extends email-layout 2 | 3 | block content 4 | h2 Password Reset 5 | p Hello. You have requested a password reset. Please click the following button to continue on with resetting your password. Please note this link is only valid for the next hour. 6 | +button(resetURL, 'Reset my Password →') 7 | p If you can't click the above button please visit #{resetURL} 8 | br 9 | p If you didn't request this email, please ignore it. 10 | -------------------------------------------------------------------------------- /views/email/styles.css: -------------------------------------------------------------------------------- 1 | /* ------------------------------------- 2 | GLOBAL RESETS 3 | ------------------------------------- */ 4 | img { 5 | border: none; 6 | -ms-interpolation-mode: bicubic; 7 | max-width: 100%; } 8 | 9 | body { 10 | background-color: #303030; 11 | font-family: sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | font-size: 14px; 14 | line-height: 1.4; 15 | margin: 0; 16 | padding: 0; 17 | -ms-text-size-adjust: 100%; 18 | -webkit-text-size-adjust: 100%; } 19 | 20 | table { 21 | border-collapse: separate; 22 | mso-table-lspace: 0pt; 23 | mso-table-rspace: 0pt; 24 | width: 100%; } 25 | table td { 26 | font-family: sans-serif; 27 | font-size: 14px; 28 | vertical-align: top; } 29 | 30 | /* ------------------------------------- 31 | BODY & CONTAINER 32 | ------------------------------------- */ 33 | 34 | .body { 35 | width: 100%; 36 | } 37 | 38 | /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */ 39 | .container { 40 | display: block; 41 | Margin: 0 auto !important; 42 | /* makes it centered */ 43 | max-width: 580px; 44 | padding: 10px; 45 | width: 580px; } 46 | 47 | /* This should also be a block element, so that it will fill 100% of the .container */ 48 | .content { 49 | box-sizing: border-box; 50 | display: block; 51 | Margin: 0 auto; 52 | max-width: 580px; 53 | padding: 10px; } 54 | 55 | /* ------------------------------------- 56 | HEADER, FOOTER, MAIN 57 | ------------------------------------- */ 58 | .main { 59 | background: #fff; 60 | border-top:20px solid #fff200; 61 | width: 100%; } 62 | 63 | .wrapper { 64 | box-sizing: border-box; 65 | padding: 20px; } 66 | 67 | .footer { 68 | clear: both; 69 | padding-top: 10px; 70 | text-align: center; 71 | width: 100%; } 72 | .footer td, 73 | .footer p, 74 | .footer span, 75 | .footer a { 76 | color: #999999; 77 | font-size: 12px; 78 | text-align: center; } 79 | 80 | /* ------------------------------------- 81 | TYPOGRAPHY 82 | ------------------------------------- */ 83 | h1, 84 | h2, 85 | h3, 86 | h4 { 87 | color: #000000; 88 | font-family: sans-serif; 89 | font-weight: 400; 90 | line-height: 1.4; 91 | margin: 0; 92 | Margin-bottom: 30px; } 93 | 94 | h1 { 95 | font-size: 35px; 96 | font-weight: 300; 97 | text-align: center; 98 | text-transform: capitalize; } 99 | 100 | p, 101 | ul, 102 | ol { 103 | font-family: sans-serif; 104 | font-size: 14px; 105 | font-weight: normal; 106 | margin: 0; 107 | Margin-bottom: 15px; } 108 | p li, 109 | ul li, 110 | ol li { 111 | list-style-position: inside; 112 | margin-left: 5px; } 113 | 114 | a { 115 | color: #fff200; 116 | text-decoration: underline; } 117 | 118 | /* ------------------------------------- 119 | BUTTONS 120 | ------------------------------------- */ 121 | .btn { 122 | box-sizing: border-box; 123 | width: 100%; } 124 | .btn > tbody > tr > td { 125 | padding-bottom: 15px; } 126 | .btn table { 127 | width: auto; } 128 | .btn table td { 129 | background-color: #ffffff; 130 | border-radius: 5px; 131 | text-align: center; } 132 | .btn a { 133 | background-color: #ffffff; 134 | border: solid 1px #fff200; 135 | border-radius: 5px; 136 | box-sizing: border-box; 137 | color: #fff200; 138 | cursor: pointer; 139 | display: inline-block; 140 | font-size: 14px; 141 | font-weight: bold; 142 | margin: 0; 143 | padding: 12px 25px; 144 | text-decoration: none; 145 | text-transform: capitalize; } 146 | 147 | .btn-primary table td { 148 | background-color: #fff200; } 149 | 150 | .btn-primary a { 151 | background-color: #fff200; 152 | border-color: #fff200; 153 | color: #3a3a3a; } 154 | 155 | /* ------------------------------------- 156 | OTHER STYLES THAT MIGHT BE USEFUL 157 | ------------------------------------- */ 158 | .last { 159 | margin-bottom: 0; } 160 | 161 | .first { 162 | margin-top: 0; } 163 | 164 | .align-center { 165 | text-align: center; } 166 | 167 | .align-right { 168 | text-align: right; } 169 | 170 | .align-left { 171 | text-align: left; } 172 | 173 | .clear { 174 | clear: both; } 175 | 176 | .mt0 { 177 | margin-top: 0; } 178 | 179 | .mb0 { 180 | margin-bottom: 0; } 181 | 182 | .preheader { 183 | color: transparent; 184 | display: none; 185 | height: 0; 186 | max-height: 0; 187 | max-width: 0; 188 | opacity: 0; 189 | overflow: hidden; 190 | mso-hide: all; 191 | visibility: hidden; 192 | width: 0; } 193 | 194 | .powered-by a { 195 | text-decoration: none; } 196 | 197 | hr { 198 | border: 0; 199 | border-bottom: 1px solid #303030; 200 | Margin: 20px 0; } 201 | 202 | /* ------------------------------------- 203 | RESPONSIVE AND MOBILE FRIENDLY STYLES 204 | ------------------------------------- */ 205 | @media only screen and (max-width: 620px) { 206 | table[class=body] h1 { 207 | font-size: 28px !important; 208 | margin-bottom: 10px !important; } 209 | table[class=body] p, 210 | table[class=body] ul, 211 | table[class=body] ol, 212 | table[class=body] td, 213 | table[class=body] span, 214 | table[class=body] a { 215 | font-size: 16px !important; } 216 | table[class=body] .wrapper, 217 | table[class=body] .article { 218 | padding: 10px !important; } 219 | table[class=body] .content { 220 | padding: 0 !important; } 221 | table[class=body] .container { 222 | padding: 0 !important; 223 | width: 100% !important; } 224 | table[class=body] .main { 225 | border-left-width: 0 !important; 226 | border-radius: 0 !important; 227 | border-right-width: 0 !important; } 228 | table[class=body] .btn table { 229 | width: 100% !important; } 230 | table[class=body] .btn a { 231 | width: 100% !important; } 232 | table[class=body] .img-responsive { 233 | height: auto !important; 234 | max-width: 100% !important; 235 | width: auto !important; }} 236 | 237 | /* ------------------------------------- 238 | PRESERVE THESE STYLES IN THE HEAD 239 | ------------------------------------- */ 240 | @media all { 241 | .ExternalClass { 242 | width: 100%; } 243 | .ExternalClass, 244 | .ExternalClass p, 245 | .ExternalClass span, 246 | .ExternalClass font, 247 | .ExternalClass td, 248 | .ExternalClass div { 249 | line-height: 100%; } 250 | .apple-link a { 251 | color: inherit !important; 252 | font-family: inherit !important; 253 | font-size: inherit !important; 254 | font-weight: inherit !important; 255 | line-height: inherit !important; 256 | text-decoration: none !important; } 257 | .btn-primary table td:hover { 258 | background-color: #00ffde !important; } 259 | .btn-primary a:hover { 260 | background-color: #00ffde !important; 261 | border-color: #00ffde !important; } } 262 | 263 | 264 | --------------------------------------------------------------------------------