diff --git a/README.md b/README.md index 1e2c8d4..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,6 +0,0 @@ -##Getting started -* `npm i` - -##Todo - -create a basic web server with expressthat can handle a GET request for whatever resource you'd like and whatever url you want. Navigate to that url in the browser and see if yuor resource is there. The solution is on the `step-1-fix` branch diff --git a/index.js b/index.js new file mode 100644 index 0000000..d164a97 --- /dev/null +++ b/index.js @@ -0,0 +1,15 @@ +// intro point for our server. +// PRO-TIP: if you have an index.js file +// on the root of a folder in node +// you can just require that folder and node will +// automatically require the index.js on the root + +// setup config first before anything by requiring it +var config = require('./server/config/config'); +var app = require('./server/server'); +var logger = require('./server/util/logger'); + +app.listen(config.port); +logger.log('listening on http://localhost:' + config.port); + + diff --git a/package.json b/package.json index ea99b7f..77daafd 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,24 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "nodemon index.js", + "test": "" }, "author": "", "license": "ISC", "dependencies": { + "bcrypt": "^0.8.3", "body-parser": "^1.13.2", + "colors": "^1.1.2", "express": "^4.13.1", + "express-jwt": "^3.0.1", + "jsonwebtoken": "^5.0.4", "lodash": "^3.10.0", + "mongoose": "^4.0.8", "morgan": "^1.6.1" + }, + "devDependencies": { + "chai": "^3.2.0", + "supertest": "^1.0.1" } } diff --git a/server.js b/server.js deleted file mode 100644 index 80f064e..0000000 --- a/server.js +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: create a basic server with express -// that can handle a GET request for what ever resource you'd like -// whatever url you want, and then navigate to that url in the browser and -// see if yuor resource is there. diff --git a/server/api/api.js b/server/api/api.js new file mode 100644 index 0000000..d438c24 --- /dev/null +++ b/server/api/api.js @@ -0,0 +1,9 @@ +var router = require('express').Router(); + +// api router will mount other routers +// for all our resources +router.use('/users', require('./user/userRoutes')); +router.use('/categories', require('./category/categoryRoutes')); +router.use('/posts', require('./post/postRoutes')); + +module.exports = router; diff --git a/server/api/category/categoryController.js b/server/api/category/categoryController.js new file mode 100644 index 0000000..fe6fcbb --- /dev/null +++ b/server/api/category/categoryController.js @@ -0,0 +1,67 @@ +var Category = require('./categoryModel'); +var _ = require('lodash'); + +exports.params = function(req, res, next, id) { + Category.findById(id) + .then(function(category) { + if (!category) { + next(new Error('No category with that id')); + } else { + req.category = category; + next(); + } + }, function(err) { + next(err); + }); +}; + +exports.get = function(req, res, next) { + Category.find({}) + .then(function(categories){ + res.json(categories); + }, function(err){ + next(err); + }); +}; + +exports.getOne = function(req, res, next) { + var category = req.category; + res.json(category); +}; + +exports.put = function(req, res, next) { + var category = req.category; + + var update = req.body; + + _.merge(category, update); + + category.save(function(err, saved) { + if (err) { + next(err); + } else { + res.json(saved); + } + }) +}; + +exports.post = function(req, res, next) { + var newcategory = req.body; + + Category.create(newcategory) + .then(function(category) { + res.json(category); + }, function(err) { + next(err); + }); +}; + +exports.delete = function(req, res, next) { + req.category.remove(function(err, removed) { + if (err) { + next(err); + } else { + res.json(removed); + } + }); +}; diff --git a/server/api/category/categoryModel.js b/server/api/category/categoryModel.js new file mode 100644 index 0000000..f974543 --- /dev/null +++ b/server/api/category/categoryModel.js @@ -0,0 +1,12 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var CategorySchema = new Schema({ + name: { + type: String, + required: true, + unique: true + } +}); + +module.exports = mongoose.model('category', CategorySchema); diff --git a/server/api/category/categoryRoutes.js b/server/api/category/categoryRoutes.js new file mode 100644 index 0000000..01af2df --- /dev/null +++ b/server/api/category/categoryRoutes.js @@ -0,0 +1,18 @@ +var router = require('express').Router(); +var logger = require('../../util/logger'); +var controller = require('./categoryController'); +var auth = require('../../auth/auth'); + +// lock down the right routes :) +router.param('id', controller.params); + +router.route('/') + .get( controller.get) + .post(controller.post) + +router.route('/:id') + .get(controller.getOne) + .put(controller.put) + .delete(controller.delete) + +module.exports = router; diff --git a/server/api/post/postController.js b/server/api/post/postController.js new file mode 100644 index 0000000..2ac1755 --- /dev/null +++ b/server/api/post/postController.js @@ -0,0 +1,72 @@ +var Post = require('./postModel'); +var _ = require('lodash'); +var logger = require('../../util/logger'); + +exports.params = function(req, res, next, id) { + Post.findById(id) + .populate('author') + .exec() + .then(function(post) { + if (!post) { + next(new Error('No post with that id')); + } else { + req.post = post; + next(); + } + }, function(err) { + next(err); + }); +}; + +exports.get = function(req, res, next) { + Post.find({}) + .populate('author categories') + .exec() + .then(function(posts){ + res.json(posts); + }, function(err){ + next(err); + }); +}; + +exports.getOne = function(req, res, next) { + var post = req.post; + res.json(post); +}; + +exports.put = function(req, res, next) { + var post = req.post; + + var update = req.body; + + _.merge(post, update); + + post.save(function(err, saved) { + if (err) { + next(err); + } else { + res.json(saved); + } + }) +}; + +exports.post = function(req, res, next) { + var newpost = req.body; + Post.create(newpost) + .then(function(post) { + res.json(post); + }, function(err) { + logger.error(err); + next(err); + }); +}; + +exports.delete = function(req, res, next) { + req.post.remove(function(err, removed) { + if (err) { + next(err); + } else { + res.json(removed); + } + }); +}; diff --git a/server/api/post/postModel.js b/server/api/post/postModel.js new file mode 100644 index 0000000..d0ae4ef --- /dev/null +++ b/server/api/post/postModel.js @@ -0,0 +1,21 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var PostSchema = new Schema({ + title: { + type: String, + required: true, + unique: true + }, + + text: { + type: String, + required: true + }, + + author: {type: Schema.Types.ObjectId, ref: 'user'}, + + categories: [{type: Schema.Types.ObjectId, ref: 'category'}] +}); + +module.exports = mongoose.model('post', PostSchema); diff --git a/server/api/post/postRoutes.js b/server/api/post/postRoutes.js new file mode 100644 index 0000000..3d10e67 --- /dev/null +++ b/server/api/post/postRoutes.js @@ -0,0 +1,18 @@ +var router = require('express').Router(); +var logger = require('../../util/logger'); +var controller = require('./postController'); +var auth = require('../../auth/auth'); + +// lock down the right routes :) +router.param('id', controller.params); + +router.route('/') + .get(controller.get) + .post(controller.post) + +router.route('/:id') + .get(controller.getOne) + .put(controller.put) + .delete(controller.delete) + +module.exports = router; diff --git a/server/api/user/userController.js b/server/api/user/userController.js new file mode 100644 index 0000000..9bbadce --- /dev/null +++ b/server/api/user/userController.js @@ -0,0 +1,68 @@ +var User = require('./userModel'); +var _ = require('lodash'); +var signToken = require('../../auth/auth').signToken; + +exports.params = function(req, res, next, id) { + User.findById(id) + .then(function(user) { + if (!user) { + next(new Error('No user with that id')); + } else { + req.user = user; + next(); + } + }, function(err) { + next(err); + }); +}; + +exports.get = function(req, res, next) { + User.find({}) + .then(function(users){ + res.json(users); + }, function(err){ + next(err); + }); +}; + +exports.getOne = function(req, res, next) { + var user = req.user; + res.json(user); +}; + +exports.put = function(req, res, next) { + var user = req.user; + + var update = req.body; + + _.merge(user, update); + + user.save(function(err, saved) { + if (err) { + next(err); + } else { + res.json(saved); + } + }) +}; + +exports.post = function(req, res, next) { + var newUser = new User(req.body); + + newUser.save(function(err, user) { + if(err) {next(err);} + + var token = signToken(user._id); + res.json({token: token}); + }); +}; + +exports.delete = function(req, res, next) { + req.user.remove(function(err, removed) { + if (err) { + next(err); + } else { + res.json(removed); + } + }); +}; diff --git a/server/api/user/userModel.js b/server/api/user/userModel.js new file mode 100644 index 0000000..b64b221 --- /dev/null +++ b/server/api/user/userModel.js @@ -0,0 +1,41 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var bcrypt = require('bcrypt'); +var UserSchema = new Schema({ + username: { + type: String, + required: true, + unique: true + }, + + // dont store the password as plain text + password: { + type: String, + required: true + } +}); + +UserSchema.pre('save', function(next) { + if (!this.isModified('password')) return next(); + this.password = this.encryptPassword(this.password); + next(); +}) + + +UserSchema.methods = { + // check the passwords on signin + authenticate: function(plainTextPword) { + return bcrypt.compareSync(plainTextPword, this.password); + }, + // hash the passwords + encryptPassword: function(plainTextPword) { + if (!plainTextPword) { + return '' + } else { + var salt = bcrypt.genSaltSync(10); + return bcrypt.hashSync(plainTextPword, salt); + } + } +}; + +module.exports = mongoose.model('user', UserSchema); diff --git a/server/api/user/userRoutes.js b/server/api/user/userRoutes.js new file mode 100644 index 0000000..a6b8919 --- /dev/null +++ b/server/api/user/userRoutes.js @@ -0,0 +1,18 @@ +var router = require('express').Router(); +var logger = require('../../util/logger'); +var controller = require('./userController'); +var auth = require('../../auth/auth'); + +// lock down the right routes :) +router.param('id', controller.params); + +router.route('/') + .get(controller.get) + .post(controller.post) + +router.route('/:id') + .get(controller.getOne) + .put(controller.put) + .delete(controller.delete) + +module.exports = router; diff --git a/server/auth/auth.js b/server/auth/auth.js new file mode 100644 index 0000000..d79a84a --- /dev/null +++ b/server/auth/auth.js @@ -0,0 +1,90 @@ +var jwt = require('jsonwebtoken'); +var expressJwt = require('express-jwt'); +var config = require('../config/config'); +var checkToken = expressJwt({ secret: config.secrets.jwt }); +var User = require('../api/user/userModel'); + +exports.decodeToken = function() { + return function(req, res, next) { + // make it optional to place token on query string + // if it is, place it on the headers where it should be + // so checkToken can see it. See follow the 'Bearer 034930493' format + // so checkToken can see it and decode it + if (req.query && req.query.hasOwnProperty('access_token')) { + req.headers.authorization = 'Bearer ' + req.query.access_token; + } + + // this will call next if token is valid + // and send error if its not. It will attached + // the decoded token to req.user + checkToken(req, res, next); + }; +}; + +exports.getFreshUser = function() { + return function(req, res, next) { + User.findById(req.user._id) + .then(function(user) { + if (!user) { + // if no user is found it was not + // it was a valid JWT but didn't decode + // to a real user in our DB. Either the user was deleted + // since the client got the JWT, or + // it was a JWT from some other source + res.status(401).send('Unauthorized'); + } else { + // update req.user with fresh user from + // stale token data + req.user = user; + next(); + } + }, function(err) { + next(err); + }); + } +}; + +exports.verifyUser = function() { + return function(req, res, next) { + var username = req.body.username; + var password = req.body.password; + + // if no username or password then send + if (!username || !password) { + res.status(400).send('You need a username and password'); + return; + } + + // look user up in the DB so we can check + // if the passwords match for the username + User.findOne({username: username}) + .then(function(user) { + if (!user) { + res.status(401).send('No user with the given username'); + } else { + // checking the passowords here + if (!user.authenticate(password)) { + res.status(401).send('Wrong password'); + } else { + // if everything is good, + // then attach to req.user + // and call next so the controller + // can sign a token from the req.user._id + req.user = user; + next(); + } + } + }, function(err) { + next(err); + }); + }; +}; + +// util method to sign tokens on signup +exports.signToken = function(id) { + return jwt.sign( + {_id: id}, + config.secrets.jwt, + {expiresInMinutes: config.expireTime} + ); +}; diff --git a/server/auth/controller.js b/server/auth/controller.js new file mode 100644 index 0000000..38f1fe3 --- /dev/null +++ b/server/auth/controller.js @@ -0,0 +1,10 @@ +var User = require('../api/user/userModel'); +var signToken = require('./auth').signToken; + +exports.signin = function(req, res, next) { + // req.user will be there from the middleware + // verify user. Then we can just create a token + // and send it back for the client to consume + var token = signToken(req.user._id); + res.json({token: token}); +}; diff --git a/server/auth/routes.js b/server/auth/routes.js new file mode 100644 index 0000000..9aa7b7f --- /dev/null +++ b/server/auth/routes.js @@ -0,0 +1,9 @@ +var router = require('express').Router(); +var verifyUser = require('./auth').verifyUser; +var controller = require('./controller'); + +// before we send back a jwt, lets check +// the password and username match what is in the DB +router.post('/signin', verifyUser(), controller.signin); + +module.exports = router; diff --git a/server/config/config.js b/server/config/config.js new file mode 100644 index 0000000..11bf35e --- /dev/null +++ b/server/config/config.js @@ -0,0 +1,34 @@ +var _ = require('lodash'); + +var config = { + dev: 'development', + test: 'testing', + prod: 'production', + port: process.env.PORT || 3000, + // 10 days in minutes + expireTime: 24 * 60 * 10, + secrets: { + jwt: process.env.JWT || 'gumball' + } +}; + +process.env.NODE_ENV = process.env.NODE_ENV || config.dev; +config.env = process.env.NODE_ENV; + +var envConfig; +// require could error out if +// the file don't exist so lets try this statement +// and fallback to an empty object if it does error out +try { + envConfig = require('./' + config.env); + // just making sure the require actually + // got something back :) + envConfig = envConfig || {}; +} catch(e) { + envConfig = {}; +} + +// merge the two config files together +// the envConfig file will overwrite properties +// on the config object +module.exports = _.merge(config, envConfig); diff --git a/server/config/development.js b/server/config/development.js new file mode 100644 index 0000000..82f6eee --- /dev/null +++ b/server/config/development.js @@ -0,0 +1,8 @@ +module.exports = { + // enabled logging for development + logging: true, + seed: true, + db: { + url: 'mongodb://localhost/nodeblog' + } +}; diff --git a/server/config/production.js b/server/config/production.js new file mode 100644 index 0000000..eab9e02 --- /dev/null +++ b/server/config/production.js @@ -0,0 +1,4 @@ +module.exports = { + // disbable logging for production + logging: false +}; diff --git a/server/config/testing.js b/server/config/testing.js new file mode 100644 index 0000000..d9ff459 --- /dev/null +++ b/server/config/testing.js @@ -0,0 +1,7 @@ +module.exports = { + // disbable logging for testing + logging: false, + db: { + url: 'mongodb://localhost/nodeblog-test' + } +}; diff --git a/server/middleware/appMiddlware.js b/server/middleware/appMiddlware.js new file mode 100644 index 0000000..9086014 --- /dev/null +++ b/server/middleware/appMiddlware.js @@ -0,0 +1,11 @@ +var morgan = require('morgan'); +var bodyParser = require('body-parser'); +var cors = require('cors'); +// setup global middleware here + +module.exports = function(app) { + app.use(morgan('dev')); + app.use(bodyParser.urlencoded({ extended: true })); + app.use(bodyParser.json()); + app.use(cors()); +}; diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..12686e1 --- /dev/null +++ b/server/server.js @@ -0,0 +1,33 @@ +var express = require('express'); +var app = express(); +var api = require('./api/api'); +var config = require('./config/config'); +var logger = require('./util/logger'); +var auth = require('./auth/routes'); +// db.url is different depending on NODE_ENV +require('mongoose').connect(config.db.url); + +if (config.seed) { + require('./util/seed'); +} +// setup the app middlware +require('./middleware/appMiddlware')(app); + +// setup the api +app.use('/api', api); +app.use('/auth', auth); +// set up global error handling + +app.use(function(err, req, res, next) { + // if error thrown from jwt validation check + if (err.name === 'UnauthorizedError') { + res.status(401).send('Invalid token'); + return; + } + + logger.error(err.stack); + res.status(500).send('Oops'); +}); + +// export the app for testing +module.exports = app; diff --git a/server/util/logger.js b/server/util/logger.js new file mode 100644 index 0000000..0067f37 --- /dev/null +++ b/server/util/logger.js @@ -0,0 +1,51 @@ +// no var needed here, colors will attached colors +// to String.prototype +require('colors'); +var _ = require('lodash'); + +var config = require('../config/config'); + +// create a noop (no operation) function for when loggin is disabled +var noop = function(){}; +// check if loggin is enabled in the config +// if it is, then use console.log +// if not then noop +var consoleLog = config.logging ? console.log.bind(console) : noop; + +var logger = { + log: function() { + var tag = '[ ✨ LOG ✨ ]'.green; + // arguments is an array like object with all the passed + // in arguments to this function + var args = _.toArray(arguments) + .map(function(arg) { + if(typeof arg === 'object') { + // turn the object to a string so we + // can log all the properties and color it + var string = JSON.stringify(arg, null, 2); + return tag + ' ' + string.cyan; + } else { + return tag + ' ' + arg.cyan; + } + }); + + // call either console.log or noop here + // with the console object as the context + // and the new colored args :) + consoleLog.apply(console, args); + }, + + error: function() { + var args = _.toArray(arguments) + .map(function(arg) { + arg = arg.stack || arg; + var name = arg.name || '[ ❌ ERROR ❌ ]'; + var log = name.yellow + ' ' + arg.red; + return log; + }); + + consoleLog.apply(console, args); + } +}; + +module.exports = logger; diff --git a/server/util/seed.js b/server/util/seed.js new file mode 100644 index 0000000..b7d5872 --- /dev/null +++ b/server/util/seed.js @@ -0,0 +1,99 @@ +var User = require('../api/user/userModel'); +var Post = require('../api/post/postModel'); +var Category = require('../api/category/categoryModel'); +var _ = require('lodash'); +var logger = require('./logger'); + +logger.log('Seeding the Database'); + +var users = [ + {username: 'Jimmylo', password: 'test'}, + {username: 'Xoko', password: 'test'}, + {username: 'katamon', password: 'test'} +]; + +var categories = [ + {name: 'intros'}, + {name: 'angular'}, + {name: 'UI/UX'} +]; + +var posts = [ + {title: 'Learn angular 2 today', text: 'Angular to is so dope'}, + {title: '10 reasons you should love IE7', text: 'IE7 is so amazing'}, + {title: 'Why we switched to Go', text: 'go is dope'} +]; + +var createDoc = function(model, doc) { + return new Promise(function(resolve, reject) { + new model(doc).save(function(err, saved) { + return err ? reject(err) : resolve(saved); + }); + }); +}; + +var cleanDB = function() { + logger.log('... cleaning the DB'); + var cleanPromises = [User, Category, Post] + .map(function(model) { + return model.remove().exec(); + }); + return Promise.all(cleanPromises); +} + +var createUsers = function(data) { + + var promises = users.map(function(user) { + return createDoc(User, user); + }); + + return Promise.all(promises) + .then(function(users) { + return _.merge({users: users}, data || {}); + }); +}; + +var createCategories = function(data) { + var promises = categories.map(function(category) { + return createDoc(Category, category); + }); + + return Promise.all(promises) + .then(function(categories) { + return _.merge({categories: categories}, data || {}); + }); +}; + +var createPosts = function(data) { + var addCategory = function(post, category) { + post.categories.push(category); + + return new Promise(function(resolve, reject) { + post.save(function(err, saved) { + return err ? reject(err) : resolve(saved) + }); + }); + }; + + var newPosts = posts.map(function(post, i) { + post.author = data.users[i]._id; + return createDoc(Post, post); + }); + + return Promise.all(newPosts) + .then(function(savedPosts) { + return Promise.all(savedPosts.map(function(post, i){ + return addCategory(post, data.categories[i]) + })); + }) + .then(function() { + return 'Seeded DB with 3 Posts, 3 Users, 3 Categories'; + }); +}; + +cleanDB() + .then(createUsers) + .then(createCategories) + .then(createPosts) + .then(logger.log.bind(logger)) + .catch(logger.log.bind(logger));