diff options
| author | Ben Beltran <ben@nsovocal.com> | 2017-01-31 00:53:36 -0600 |
|---|---|---|
| committer | Ben Beltran <ben@nsovocal.com> | 2017-01-31 00:53:36 -0600 |
| commit | a3f9e2603dfdf8c492ec0dc355cd434fc6100f06 (patch) | |
| tree | 52c84831a3a6070e3e01c5ada5b862fd56f28327 /lib | |
| parent | 7eb26514c478cfa06a797e9d63a29ef6a6d16d59 (diff) | |
| parent | f74591da2d5c42b8a1e658d17310e604bedcf856 (diff) | |
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/dasein.js | 165 | ||||
| -rw-r--r-- | lib/handlers/auth.js | 113 | ||||
| -rw-r--r-- | lib/handlers/comments.js | 140 | ||||
| -rw-r--r-- | lib/handlers/posts.js | 179 | ||||
| -rw-r--r-- | lib/twitter_helper.js | 142 |
5 files changed, 739 insertions, 0 deletions
diff --git a/lib/dasein.js b/lib/dasein.js new file mode 100644 index 0000000..5b8486a --- /dev/null +++ b/lib/dasein.js @@ -0,0 +1,165 @@ +'use strict'; + +const Koa = require('koa'); +const KoaBodyParser = require('koa-bodyparser'); +const KoaJwt = require('koa-jwt'); +const KoaRoute = require('koa-route'); +const KoaSend = require('koa-send'); +const KoaStatic = require('koa-static'); +const Path = require('path'); + +const AuthHandler = require('./handlers/auth'); +const PostsHandler = require('./handlers/posts'); +const CommentsHandler = require('./handlers/comments'); + +const internals = {}; + +internals.k401Location = '/401.html'; +internals.kMainLocation = '/'; + +/** + * The Dasein class is the main entry point for the application. + * + * @class Dasein + * @param {Dasein.tConfiguration} config the initialization options to + * extend the instance + */ +module.exports = internals.Dasein = class Dasein { + + constructor(config) { + + Object.assign(this, config); + } + + /** + * Initializes the application and starts listening. Also prints a + * nice robotic banner with information. + * + * @function run + * @memberof Dasein + * @instance + */ + run() { + + this._initializeServer(); + this._startServer(); + this._printBanner(); + + return Promise.resolve(); + } + + // Initializes the Koa application and all the handlers. + + _initializeServer() { + + const self = this; + + this._app = Koa(); + + this._app.use(KoaStatic(this.staticDirectory)); + this._app.use(KoaBodyParser()); + + // Error handler + + this._app.use(function * (next) { + + try { + yield next; + } + catch (err) { + this.status = err.status || 500; + + const response = { + error: err.message, + status: this.status + }; + + if (response.status === 401) { + response.error === 'Protected resource, use Authorization header to get access'; + } + + this.body = response; + + this.app.emit('error', err, this); + } + }); + + this._app.use(KoaJwt({ + secret: this.jwt.secret, + passthrough: true + })); + + this._initializeAuthRoutes(); + this._initializePostsRoutes(); + this._initializeCommentsRoutes(); + + this._app.use(function * () { + + yield KoaSend(this, Path.join(self.staticDirectory, 'index.html')); + }); + + } + + // Initialize routes for auth + + _initializeAuthRoutes() { + + const authHandler = new AuthHandler({ + jwt: this.jwt, + twitter: this.twitter + }); + this._app.use(KoaRoute.get('/api/auth/login', authHandler.login())); + this._app.use(KoaRoute.post('/api/auth/callback', authHandler.callback())); + } + + // Initialize routes for posts + + _initializePostsRoutes() { + + const postsHandler = new PostsHandler({ + ttl: this.ttl, + redis: this.redis + }); + this._app.use(KoaRoute.get('/api/posts', postsHandler.findAll())); + this._app.use(KoaRoute.get('/api/posts/:id', postsHandler.find())); + this._app.use(KoaRoute.post('/api/posts', postsHandler.create())); + this._app.use(KoaRoute.delete('/api/posts/:id', postsHandler.delete())); + + } + + // Initialize routes for comments + + _initializeCommentsRoutes() { + + const commentsHandler = new CommentsHandler({ + ttl: this.ttl, + redis: this.redis + }); + this._app.use(KoaRoute.get('/api/posts/:postId/comments', commentsHandler.findAll())); + this._app.use(KoaRoute.post('/api/posts/:postId/comments', commentsHandler.create())); + } + + // Starts listening + + _startServer() { + + this._app.listen(this.port); + } + + // Prints the banner. + + _printBanner() { + + console.log(' .'); + console.log(' /'); + console.log(' +-----+'); + console.log(` | o o | - Listening Gladly, Try me on port: ${this.port}`); + console.log(' +-----+'); + console.log(' +---------+'); + console.log(' /| [][] |\\'); + console.log(' || | |'); + console.log(' || | \\c'); + console.log(' ^+---------+'); + console.log(' (.) '); + } +}; diff --git a/lib/handlers/auth.js b/lib/handlers/auth.js new file mode 100644 index 0000000..1db9494 --- /dev/null +++ b/lib/handlers/auth.js @@ -0,0 +1,113 @@ +'use strict'; + +const Co = require('co'); +const JsonWebToken = require('jsonwebtoken'); +const Pify = require('pify'); +const TwitterHelper = require('../twitter_helper'); + +const internals = {}; + +internals.kRedirectUrl = 'https://api.twitter.com/oauth/authenticate?oauth_token='; +internals.kLoginRedirect = '/login'; + +internals.signJsonWebToken = Pify(JsonWebToken.sign); + +/** + * Handles the HTTP requests for auth related operations. + * + * @class AuthHandler + * @param {Dasein.tConfiguration} config The configuration to + * initialize. + */ +module.exports = internals.AuthHandler = class AuthHandler { + + constructor(config) { + + this._twitterHelper = new TwitterHelper(config.twitter); + this._jwtConfig = config.jwt; + } + + /** + * Triggers the twitter login flow. Redirects to twitter's oauth + * request page + * + * @function login + * @memberof AuthHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + login() { + + const twitterHelper = this._twitterHelper; + + return function *handleLogin() { + + const requestToken = yield twitterHelper.getRequestToken(); + const loginUrl = `${internals.kRedirectUrl}${requestToken.oAuthToken}`; + + this.body = { loginUrl }; + }; + } + + /** + * Handles twitter's callback. Fetches the oAuth Verifier, attempts to + * obtain a user object and responds with the JWT + * + * @function callback + * @memberof AuthHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + callback() { + + const self = this; + + return function *handleCallback() { + + if (this.request.query.denied) { + return this.throw(401); + } + + const oAuthToken = this.request.body.oAuthToken; + const oAuthVerifier = this.request.body.oAuthVerifier; + let user; + + try { + const accessToken = yield self._twitterHelper.getAccessToken(oAuthToken, oAuthVerifier); + user = yield self._twitterHelper.getUser(accessToken.oAuthAccessToken, accessToken.oAuthAccessTokenSecret); + } + catch (err) { + console.error(err.stack || err.message || err); + return this.throw(401); + } + + const expiresAt = Date.now() + self._jwtConfig.duration * 1000; + + const token = yield self._getToken(user); + + const response = { + expiresAt, + user, + token + }; + + this.body = response; + }; + } + + // Generates a JSON Web Token + + _getToken(payload) { + + const self = this; + + return Co(function * () { + + const token = yield internals.signJsonWebToken(payload, self._jwtConfig.secret, { + expiresIn: self._jwtConfig.duration + }); + + return token; + }); + } +}; diff --git a/lib/handlers/comments.js b/lib/handlers/comments.js new file mode 100644 index 0000000..14bd137 --- /dev/null +++ b/lib/handlers/comments.js @@ -0,0 +1,140 @@ +'use strict'; + +const Joi = require('joi'); +const Pify = require('pify'); +const Redis = require('redis'); +const UUID = require('uuid/v4'); + +const internals = {}; + +internals.kPostsPrefix = 'posts'; +internals.kCommentsPrefix = 'comments'; +internals.kMaxCommentSize = 255; + +internals.kCommentsSchema = Joi.object().keys({ + uuid: Joi.string().required(), + content: Joi.string().max(internals.kMaxCommentSize).required(), + timestamp: Joi.number().integer().required(), + userId: Joi.string().required(), + userName: Joi.string().required(), + userImage: Joi.string().required() +}); + +/** + * Handles the HTTP requests for comment related operations + * + * @class CommentsHandler + * @param {Dasein.tConfiguration} config The configuration to + * initialize. + */ +module.exports = internals.CommentsHandler = class CommentsHandler { + constructor(config) { + + this._ttl = config.ttl; + this._redis = Redis.createClient(config.redis); + + // Log an error if it happens. + this._redis.on('error', (err) => { + + console.error(err); + }); + } + + /** + * Fetches all available comments + * + * @function findAll + * @memberof CommentsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + findAll() { + + const self = this; + + return function * (postId) { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const scan = Pify(self._redis.scan.bind(self._redis)); + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + + const commentsKey = `${internals.kCommentsPrefix}:${postId}:*`; + let keys = []; + let nextCursor = 0; + let currentKeys = null; + + do { + [nextCursor, currentKeys] = yield scan(nextCursor || 0, 'MATCH', commentsKey); + keys = keys.concat(currentKeys); + } while (nextCursor > 0); + + const comments = yield keys.map((key) => hgetall(key)); + + this.body = comments.sort((a, b) => a.timestamp - b.timestamp); + }; + } + + /** + * Creates a comment + * + * @function create + * @memberof CommentsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + create() { + + const self = this; + + return function * (postId) { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const hmset = Pify(self._redis.hmset.bind(self._redis)); + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + const expire = Pify(self._redis.expire.bind(self._redis)); + + const uuid = UUID(); + const timestamp = Date.now(); + const user = this.state.user; + + const postKey = `${internals.kPostsPrefix}:${postId}`; + const commentKey = `${internals.kCommentsPrefix}:${postId}:${uuid}`; + + const comment = { + uuid, + content: this.request.body.content, + timestamp, + userId: user.screen_name, + userName: user.name, + userImage: user.profile_image_url_https + }; + + yield self._validate(comment).catch((err) => { + + this.throw(err.message, 422); + }); + + yield hmset(commentKey, comment); + yield expire(commentKey, self._ttl * 100); // this is me being lazy :( + // comments will last at most 100 bumps + // but will disappear eventually + yield expire(postKey, self._ttl); // bumps the parent comment TTL + + this.body = yield hgetall(commentKey); + }; + } + + // Validates the comment schema + + _validate(comment) { + + const validate = Pify(Joi.validate.bind(Joi)); + return validate(comment, internals.kCommentsSchema); + } +}; diff --git a/lib/handlers/posts.js b/lib/handlers/posts.js new file mode 100644 index 0000000..b5e4f0e --- /dev/null +++ b/lib/handlers/posts.js @@ -0,0 +1,179 @@ +'use strict'; + +const Joi = require('joi'); +const Pify = require('pify'); +const Redis = require('redis'); +const UUID = require('uuid/v4'); + +const internals = {}; + +internals.kPostsPrefix = 'posts'; +internals.kMaxPostSize = 255; + +internals.kPostsSchema = Joi.object().keys({ + uuid: Joi.string().required(), + content: Joi.string().max(internals.kMaxPostSize).required(), + timestamp: Joi.number().integer().required(), + userId: Joi.string().required(), + userName: Joi.string().required(), + userImage: Joi.string().required() +}); + +/** + * Handles the HTTP requests for posts related operations + * + * @class PostsHandler + * @param {Dasein.tConfiguration} config The configuration to + * initialize. + */ +module.exports = internals.PostsHandler = class PostsHandler { + constructor(config) { + + this._ttl = config.ttl; + this._redis = Redis.createClient(config.redis); + + // Log an error if it happens. + this._redis.on('error', (err) => { + + console.error(err); + }); + } + + /** + * Fetches all available posts + * + * @function findAll + * @memberof PostsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + findAll() { + + const self = this; + + return function * () { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const scan = Pify(self._redis.scan.bind(self._redis)); + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + + let keys = []; + let nextCursor = 0; + let currentKeys = null; + + do { + [nextCursor, currentKeys] = yield scan(nextCursor || 0, 'MATCH', `${internals.kPostsPrefix}:*`); + keys = keys.concat(currentKeys); + } while (nextCursor > 0); + + const posts = yield keys.map((key) => hgetall(key)); + + this.body = posts.sort((a, b) => b.timestamp - a.timestamp); + }; + } + + /** + * Fetches a single post + * + * @function find + * @memberof PostsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + find() { + + const self = this; + + return function * (uuid) { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + + const postKey = `${internals.kPostsPrefix}:${uuid}`; + + const post = yield hgetall(postKey); + + if (!post) { + this.throw('Post not found', 404); + } + + this.body = post; + }; + } + + /** + * Creates a post + * + * @function create + * @memberof PostsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + create() { + + const self = this; + + return function * () { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const hmset = Pify(self._redis.hmset.bind(self._redis)); + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + const expire = Pify(self._redis.expire.bind(self._redis)); + + const uuid = UUID(); + const timestamp = Date.now(); + const user = this.state.user; + + const postKey = `${internals.kPostsPrefix}:${uuid}`; + + const post = { + uuid, + content: this.request.body.content, + timestamp, + userId: user.screen_name, + userName: user.name, + userImage: user.profile_image_url_https + }; + + yield self._validate(post).catch((err) => { + + this.throw(err.message, 422); + }); + + yield hmset(postKey, post); + yield expire(postKey, self._ttl); + + this.body = yield hgetall(postKey); + }; + } + + /** + * Deletes a post + * + * @function delete + * @memberof PostsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + delete() { + + return function * () {}; + } + + // Validates the post schema + + _validate(post) { + + const validate = Pify(Joi.validate.bind(Joi)); + return validate(post, internals.kPostsSchema); + } +}; diff --git a/lib/twitter_helper.js b/lib/twitter_helper.js new file mode 100644 index 0000000..6edf030 --- /dev/null +++ b/lib/twitter_helper.js @@ -0,0 +1,142 @@ +'use strict'; + +const Co = require('co'); +const OAuth = require('oauth'); +const Pify = require('pify'); + +const internals = {}; + +internals.kRequestTokenUrl = 'https://api.twitter.com/oauth/request_token'; +internals.kAccessTokenUrl = 'https://api.twitter.com/oauth/access_token'; +internals.kVerifyCredentialsUrl = 'https://api.twitter.com/1.1/account/verify_credentials.json'; +internals.kOauthVersion = '1.0A'; +internals.kOauthSignatureMethod = 'HMAC-SHA1'; + +/** + * Helper to communicate with the twitter API + * + * @class TwitterHelper + * @param {Dasein.tTwitterConfiguration} config the configuration to + * initialize the twitter API + * @see {@link https://dev.twitter.com/web/sign-in/implementing|Implementing + * Sign in with Twitter} + * + */ +module.exports = internals.TwitterHelper = class TwitterHelper { + + constructor(config) { + + this._oAuth = new OAuth.OAuth( + internals.kRequestTokenUrl, + internals.kAccessTokenUrl, + config.consumerKey, + config.consumerSecret, + internals.kOauthVersion, + null, + internals.kOauthSignatureMethod + ); + } + + /** + * Calls the API to get a request token. + * + * @function getRequestToken + * @memberof TwitterHelper + * @instance + * @return {Promise<TwitterHelper.tRequestToken>} the request token response + */ + getRequestToken() { + + const self = this; + + return Co(function * () { + + const getOAuthRequestToken = Pify(self._oAuth.getOAuthRequestToken.bind(self._oAuth), { multiArgs: true }); + const [oAuthToken, oAuthTokenSecret] = yield getOAuthRequestToken(); + + /** + * The request token and secret pair from the twitter API + * + * @memberof TwitterHelper + * @typedef {object} tRequestToken + * @property {string} oAuthToken The oAuth request token + * @property {string} oAuthTokenSecret The oAuth request token + * secret + */ + return { + oAuthToken, + oAuthTokenSecret + }; + }); + } + + /** + * Calls the API to get an access token + * + * @function getAccessToken + * @memberof TwitterHelper + * @instance + * @param {string} oAuthToken An oAuth request token + * @param {string} oAuthVerifier An oAuth verifier sent from the + * twitter callback + * @return {Promise<TwitterHelper.tAccessToken>} the acess token response + */ + getAccessToken(oAuthToken, oAuthVerifier) { + + const self = this; + + return Co(function * () { + + const getOAuthAccessToken = Pify(self._oAuth.getOAuthAccessToken.bind(self._oAuth), { multiArgs: true }); + const [oAuthAccessToken, oAuthAccessTokenSecret] = yield getOAuthAccessToken(oAuthToken, + '', + oAuthVerifier); + + /** + * The access token and secret pair from the twitter API + * + * @memberof TwitterHelper + * @typedef {object} tAccessToken + * @property {string} oAuthAccessToken The oAuth access token + * @property {string} oAuthAccessTokenSecret The oAuth access token + * secret + */ + return { + oAuthAccessToken, + oAuthAccessTokenSecret + }; + }); + } + + /** + * Gets a user object from twitter + * + * @function getUser + * @memberof TwitterHelper + * @instance + * @param {string} oAuthAccessToken An oAuth access token + * @param {string} oAuthAccessTokenSecret An oAuth access token secret + * @return {Promise<external:TwitterUser>} the user object from + * twitter + */ + getUser(oAuthAccessToken, oAuthAccessTokenSecret) { + + const self = this; + + return Co(function * () { + + const get = Pify(self._oAuth.get.bind(self._oAuth), { multiArgs: true }); + const [userResponse] = yield get(internals.kVerifyCredentialsUrl, + oAuthAccessToken, + oAuthAccessTokenSecret); + + /** + * The twitter user from the API + * @external TwitterUser + * @see {@link https://dev.twitter.com/overview/api/users|Twitter + * Api Overview: Users} + */ + return JSON.parse(userResponse); + }); + } +}; |