aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/dasein.js165
-rw-r--r--lib/handlers/auth.js113
-rw-r--r--lib/handlers/comments.js140
-rw-r--r--lib/handlers/posts.js179
-rw-r--r--lib/twitter_helper.js142
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);
+ });
+ }
+};