refactor rxify stories

fixes many bugs
This commit is contained in:
Berkeley Martinez
2015-06-25 15:03:46 -07:00
parent 95b0884df5
commit 13590a1331
4 changed files with 380 additions and 397 deletions

View File

@ -264,9 +264,8 @@ $(document).ready(function() {
$('#story-submit').bind('click', storySubmitButtonHandler); $('#story-submit').bind('click', storySubmitButtonHandler);
}) })
.done(function(data, textStatus, xhr) { .done(function(data, textStatus, xhr) {
window.location = '/stories/' + JSON.parse(data).storyLink; window.location = '/stories/' + data.storyLink;
}); });
}; };
$('#story-submit').on('click', storySubmitButtonHandler); $('#story-submit').on('click', storySubmitButtonHandler);

View File

@ -1,17 +1,84 @@
var nodemailer = require('nodemailer'), var Rx = require('rx'),
nodemailer = require('nodemailer'),
assign = require('object.assign'),
sanitizeHtml = require('sanitize-html'), sanitizeHtml = require('sanitize-html'),
moment = require('moment'), moment = require('moment'),
mongodb = require('mongodb'), mongodb = require('mongodb'),
// debug = require('debug')('freecc:cntr:story'), // debug = require('debug')('freecc:cntr:story'),
utils = require('../utils'), utils = require('../utils'),
observeMethod = require('../utils/rx').observeMethod,
saveUser = require('../utils/rx').saveUser,
saveInstance = require('../utils/rx').saveInstance,
MongoClient = mongodb.MongoClient, MongoClient = mongodb.MongoClient,
secrets = require('../../config/secrets'); secrets = require('../../config/secrets');
var foundationDate = 1413298800000;
var time48Hours = 172800000;
var unDasherize = utils.unDasherize;
var dasherize = utils.dasherize;
var getURLTitle = utils.getURLTitle;
var transporter = nodemailer.createTransport({
service: 'Mandrill',
auth: {
user: secrets.mandrill.user,
pass: secrets.mandrill.password
}
});
function sendMailWhillyNilly(mailOptions) {
transporter.sendMail(mailOptions, function(err) {
if (err) {
console.log('err sending mail whilly nilly', err);
console.log('logging err but not carring');
}
});
}
function hotRank(timeValue, rank) {
/*
* Hotness ranking algorithm: http://amix.dk/blog/post/19588
* tMS = postedOnDate - foundationTime;
* Ranking...
* f(ts, 1, rank) = log(10)z + (ts)/45000;
*/
var z = Math.log(rank) / Math.log(10);
var hotness = z + (timeValue / time48Hours);
return hotness;
}
function sortByRank(a, b) {
return hotRank(b.timePosted - foundationDate, b.rank) -
hotRank(a.timePosted - foundationDate, a.rank);
}
function cleanData(data, opts) {
var options = assign(
{},
{
allowedTags: [],
allowedAttributes: []
},
opts || {}
);
return sanitizeHtml(data, options).replace(/";/g, '"');
}
module.exports = function(app) { module.exports = function(app) {
var router = app.loopback.Router(); var router = app.loopback.Router();
var User = app.models.User; var User = app.models.User;
var findUserById = observeMethod(User, 'findById');
var findOneUser = observeMethod(User, 'findOne');
var Story = app.models.Story; var Story = app.models.Story;
var findStory = observeMethod(Story, 'find');
var findOneStory = observeMethod(Story, 'findOne');
var findStoryById = observeMethod(Story, 'findById');
var countStories = observeMethod(Story, 'count');
var Comment = app.models.Comment; var Comment = app.models.Comment;
var findCommentById = observeMethod(Comment, 'findById');
router.get('/stories/hotStories', hotJSON); router.get('/stories/hotStories', hotJSON);
router.get('/stories/comments/:id', comments); router.get('/stories/comments/:id', comments);
@ -29,39 +96,19 @@ module.exports = function(app) {
app.use(router); app.use(router);
function hotRank(timeValue, rank) {
/*
* Hotness ranking algorithm: http://amix.dk/blog/post/19588
* tMS = postedOnDate - foundationTime;
* Ranking...
* f(ts, 1, rank) = log(10)z + (ts)/45000;
*/
var time48Hours = 172800000;
var hotness;
var z = Math.log(rank) / Math.log(10);
hotness = z + (timeValue / time48Hours);
return hotness;
}
function hotJSON(req, res, next) { function hotJSON(req, res, next) {
Story.find({ var query = {
order: 'timePosted DESC', order: 'timePosted DESC',
limit: 1000 limit: 1000
}, function(err, stories) { };
if (err) { findStory(query).subscribe(
return next(err); function(stories) {
}
var foundationDate = 1413298800000;
var sliceVal = stories.length >= 100 ? 100 : stories.length; var sliceVal = stories.length >= 100 ? 100 : stories.length;
return res.json(stories.map(function(elem) { var data = stories.sort(sortByRank).slice(0, sliceVal);
return elem; res.json(data);
}).sort(function(a, b) { },
return hotRank(b.timePosted - foundationDate, b.rank, b.headline) next
- hotRank(a.timePosted - foundationDate, a.rank, a.headline); );
}).slice(0, sliceVal));
});
} }
function hot(req, res) { function hot(req, res) {
@ -78,32 +125,11 @@ module.exports = function(app) {
}); });
} }
/*
* no used anywhere
function search(req, res) {
return res.render('stories/index', {
title: 'Search the archives of Camper News',
page: 'search'
});
}
function recent(req, res) {
return res.render('stories/index', {
title: 'Recently submitted stories on Camper News',
page: 'recent'
});
}
*/
function preSubmit(req, res) { function preSubmit(req, res) {
var data = req.query; var data = req.query;
var cleanData = sanitizeHtml(data.url, { var cleanedData = cleanData(data.url);
allowedTags: [],
allowedAttributes: []
}).replace(/";/g, '"');
if (data.url.replace(/&/g, '&') !== cleanData) {
if (data.url.replace(/&/g, '&') !== cleanedData) {
req.flash('errors', { req.flash('errors', {
msg: 'The data for this post is malformed' msg: 'The data for this post is malformed'
}); });
@ -125,46 +151,33 @@ module.exports = function(app) {
}); });
} }
function returnIndividualStory(req, res, next) { function returnIndividualStory(req, res, next) {
var dashedName = req.params.storyName; var dashedName = req.params.storyName;
var storyName = unDasherize(dashedName);
var storyName = dashedName.replace(/\-/g, ' ').trim(); findOneStory({ where: { storyLink: storyName } }).subscribe(
function(story) {
Story.find({ where: { storyLink: storyName } }, function(err, story) { if (!story) {
if (err) {
return next(err);
}
if (story.length < 1) {
req.flash('errors', { req.flash('errors', {
msg: "404: We couldn't find a story with that name. " + msg: "404: We couldn't find a story with that name. " +
'Please double check the name.' 'Please double check the name.'
}); });
return res.redirect('/stories/');
}
story = story.pop();
var dashedNameFull = story.storyLink.toLowerCase() var dashedNameFull = story.storyLink.toLowerCase()
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.replace(/\s/g, '-'); .replace(/\s/g, '-');
if (dashedNameFull !== dashedName) { if (dashedNameFull !== dashedName) {
return res.redirect('../stories/' + dashedNameFull); return res.redirect('../stories/' + dashedNameFull);
} }
return res.redirect('/stories/');
}
var userVoted = false; // true if any of votes are made by user
try { var userVoted = story.upVotes.some(function(upvote) {
var votedObj = story.upVotes.filter(function(a) { return upvote.upVotedByUsername === req.user.username;
return a['upVotedByUsername'] === req.user['profile']['username'];
}); });
if (votedObj.length > 0) {
userVoted = true;
}
} catch(e) {
userVoted = false;
}
res.render('stories/index', { res.render('stories/index', {
title: story.headline, title: story.headline,
link: story.link, link: story.link,
@ -182,7 +195,9 @@ module.exports = function(app) {
storyMetaDescription: story.metaDescription, storyMetaDescription: story.metaDescription,
hasUserVoted: userVoted hasUserVoted: userVoted
}); });
}); },
next
);
} }
function getStories(req, res, next) { function getStories(req, res, next) {
@ -228,54 +243,50 @@ module.exports = function(app) {
} }
function upvote(req, res, next) { function upvote(req, res, next) {
var data = req.body.data; var id = req.body.data.id;
Story.find({ where: { id: data.id } }, function(err, story) { var savedStory = findStoryById(id)
if (err) { .flatMap(function(story) {
return next(err);
}
story = story.pop();
story.rank += 1; story.rank += 1;
story.upVotes.push({ story.upVotes.push({
upVotedBy: req.user.id, upVotedBy: req.user.id,
upVotedByUsername: req.user.username upVotedByUsername: req.user.username
}); });
story.save(); return saveInstance(story);
// NOTE(Berks): This logic is full of wholes and race conditions })
// this could be the source of many 'can't set headers after .shareReplay();
// they are sent'
// errors. This needs cleaning
User.findOne(
{ where: { id: story.author.userId } },
function(err, user) {
if (err) { return next(err); }
user.progressTimestamps.push(Date.now() || 0); savedStory.flatMap(function(story) {
user.save(function (err) { // find story author
req.user.save(function (err) { return findUserById(story.author.userId);
if (err) { return next(err); } })
}); .flatMap(function(user) {
req.user.progressTimestamps.push(Date.now() || 0); // if user deletes account then this will not exist
if (err) { if (user) {
return next(err); user.progressTimestamps.push(Date.now());
} }
}); return saveUser(user);
} })
); .flatMap(function() {
req.user.progressTimestamps.push(Date.now());
return saveUser(req.user);
})
.flatMap(savedStory)
.subscribe(
function(story) {
return res.send(story); return res.send(story);
}); },
next
);
} }
function comments(req, res, next) { function comments(req, res, next) {
var data = req.params.id; var id = req.params.id;
Comment.find( findCommentById(id).subscribe(
{ where: { id: data } }, function(comment) {
function(err, comment) { res.send(comment);
if (err) { },
return next(err); next
} );
comment = comment.pop();
return res.send(comment);
});
} }
function newStory(req, res, next) { function newStory(req, res, next) {
@ -283,10 +294,8 @@ module.exports = function(app) {
return next(new Error('Must be logged in')); return next(new Error('Must be logged in'));
} }
var url = req.body.data.url; var url = req.body.data.url;
var cleanURL = sanitizeHtml(url, { var cleanURL = cleanData(url);
allowedTags: [],
allowedAttributes: []
}).replace(/&quot;/g, '"');
if (cleanURL !== url) { if (cleanURL !== url) {
req.flash('errors', { req.flash('errors', {
msg: "The URL you submitted doesn't appear valid" msg: "The URL you submitted doesn't appear valid"
@ -300,44 +309,46 @@ module.exports = function(app) {
if (url.search(/^https?:\/\//g) === -1) { if (url.search(/^https?:\/\//g) === -1) {
url = 'http://' + url; url = 'http://' + url;
} }
Story.find(
{ where: { link: url } },
function(err, story) {
if (err) {
return next(err);
}
if (story.length) {
req.flash('errors', {
msg: "Someone's already posted that link. Here's the discussion."
});
return res.json({
alreadyPosted: true,
storyURL: '/stories/' + story.pop().storyLink
});
}
utils.getURLTitle(url, processResponse);
}
);
function processResponse(err, story) { findStory({ where: { link: url } })
if (err) { .map(function(stories) {
res.json({ if (stories.length) {
return {
alreadyPosted: true,
storyURL: '/stories/' + stories.pop().storyLink
};
}
return {
alreadyPosted: false, alreadyPosted: false,
storyURL: url, storyURL: url
storyTitle: '', };
storyImage: '', })
storyMetaDescription: '' .flatMap(function(data) {
}); if (data.alreadyPosted) {
} else { return Rx.Observable.just(data);
res.json({ }
return Rx.Observable.fromNodeCallback(getURLTitle)(data.storyURL)
.map(function(story) {
return {
alreadyPosted: false, alreadyPosted: false,
storyURL: url, storyURL: data.storyURL,
storyTitle: story.title, storyTitle: story.title,
storyImage: story.image, storyImage: story.image,
storyMetaDescription: story.description storyMetaDescription: story.description
};
});
})
.subscribe(
function(story) {
if (story.alreadyPosted) {
req.flash('errors', {
msg: "Someone's already posted that link. Here's the discussion."
}); });
} }
} res.json(story);
},
next
);
} }
function storySubmission(req, res, next) { function storySubmission(req, res, next) {
@ -357,34 +368,29 @@ module.exports = function(app) {
link = 'http://' + link; link = 'http://' + link;
} }
Story.count({ var query = {
storyLink: { storyLink: {
like: ('^' + storyLink + '(?: [0-9]+)?$'), like: ('^' + storyLink + '(?: [0-9]+)?$'),
options: 'i' options: 'i'
} }
}, function (err, storyCount) { };
if (err) {
return next(err);
}
var savedStory = countStories(query)
.flatMap(function(storyCount) {
// if duplicate storyLink add unique number // if duplicate storyLink add unique number
storyLink = (storyCount === 0) ? storyLink : storyLink + ' ' + storyCount; storyLink = (storyCount === 0) ?
storyLink :
storyLink + ' ' + storyCount;
var link = data.link; var link = data.link;
if (link.search(/^https?:\/\//g) === -1) { if (link.search(/^https?:\/\//g) === -1) {
link = 'http://' + link; link = 'http://' + link;
} }
var story = new Story({ var newStory = new Story({
headline: sanitizeHtml(data.headline, { headline: cleanData(data.headline),
allowedTags: [],
allowedAttributes: []
}).replace(/&quot;/g, '"'),
timePosted: Date.now(), timePosted: Date.now(),
link: link, link: link,
description: sanitizeHtml(data.description, { description: cleanData(data.description),
allowedTags: [],
allowedAttributes: []
}).replace(/&quot;/g, '"'),
rank: 1, rank: 1,
upVotes: [({ upVotes: [({
upVotedBy: req.user.id, upVotedBy: req.user.id,
@ -402,21 +408,20 @@ module.exports = function(app) {
metaDescription: data.storyMetaDescription, metaDescription: data.storyMetaDescription,
originalStoryAuthorEmail: req.user.email originalStoryAuthorEmail: req.user.email
}); });
story.save(function (err) { return saveInstance(newStory);
if (err) {
return next(err);
}
req.user.progressTimestamps.push(Date.now() || 0);
req.user.save(function (err) {
if (err) {
return next(err);
}
res.send(JSON.stringify({
storyLink: story.storyLink.replace(/\s+/g, '-').toLowerCase()
}));
});
}); });
req.user.progressTimestamps.push(Date.now());
return saveUser(req.user)
.flatMap(savedStory)
.subscribe(
function(story) {
res.json({
storyLink: dasherize(story.storyLink)
}); });
},
next
);
} }
function commentSubmit(req, res, next) { function commentSubmit(req, res, next) {
@ -424,11 +429,8 @@ module.exports = function(app) {
if (!req.user) { if (!req.user) {
return next(new Error('Not authorized')); return next(new Error('Not authorized'));
} }
var sanitizedBody = sanitizeHtml(data.body, var sanitizedBody = cleanData(data.body);
{
allowedTags: [],
allowedAttributes: []
}).replace(/&quot;/g, '"');
if (data.body !== sanitizedBody) { if (data.body !== sanitizedBody) {
req.flash('errors', { req.flash('errors', {
msg: 'HTML is not allowed' msg: 'HTML is not allowed'
@ -453,7 +455,13 @@ module.exports = function(app) {
commentOn: Date.now() commentOn: Date.now()
}); });
commentSave(comment, Story, res, next); commentSave(comment, findStoryById).subscribe(
function() {},
next,
function() {
res.send(true);
}
);
} }
function commentOnCommentSubmit(req, res, next) { function commentOnCommentSubmit(req, res, next) {
@ -462,13 +470,7 @@ module.exports = function(app) {
return next(new Error('Not authorized')); return next(new Error('Not authorized'));
} }
var sanitizedBody = sanitizeHtml( var sanitizedBody = cleanData(data.body);
data.body,
{
allowedTags: [],
allowedAttributes: []
}
).replace(/&quot;/g, '"');
if (data.body !== sanitizedBody) { if (data.body !== sanitizedBody) {
req.flash('errors', { req.flash('errors', {
@ -494,119 +496,101 @@ module.exports = function(app) {
topLevel: false, topLevel: false,
commentOn: Date.now() commentOn: Date.now()
}); });
commentSave(comment, Comment, res, next); commentSave(comment, findCommentById).subscribe(
function() {},
next,
function() {
res.send(true);
}
);
} }
function commentEdit(req, res, next) { function commentEdit(req, res, next) {
findCommentById(req.params.id)
Comment.find({ where: { id: req.params.id } }, function(err, cmt) { .doOnNext(function(comment) {
if (err) { if (!req.user && comment.author.userId !== req.user.id) {
return next(err); throw new Error('Not authorized');
} }
cmt = cmt.pop(); })
.flatMap(function(comment) {
if (!req.user && cmt.author.userId !== req.user.id) { var sanitizedBody = cleanData(req.body.body);
return next(new Error('Not authorized'));
}
var sanitizedBody = sanitizeHtml(req.body.body, {
allowedTags: [],
allowedAttributes: []
}).replace(/&quot;/g, '"');
if (req.body.body !== sanitizedBody) { if (req.body.body !== sanitizedBody) {
req.flash('errors', { req.flash('errors', {
msg: 'HTML is not allowed' msg: 'HTML is not allowed'
}); });
return res.send(true);
}
cmt.body = sanitizedBody;
cmt.commentOn = Date.now();
cmt.save(function(err) {
if (err) {
return next(err);
} }
comment.body = sanitizedBody;
comment.commentOn = Date.now();
return saveInstance(comment);
})
.subscribe(
function() {
res.send(true); res.send(true);
}); },
next
}); );
} }
function commentSave(comment, Context, res, next) { function commentSave(comment, findContextById) {
comment.save(function(err, data) { return saveInstance(comment)
if (err) { .flatMap(function(comment) {
return next(err);
}
try {
// Based on the context retrieve the parent // Based on the context retrieve the parent
// object of the comment (Story/Comment) // object of the comment (Story/Comment)
Context.find({ return findContextById(comment.associatedPost);
where: { id: data.associatedPost } })
}, function (err, associatedContext) { .flatMap(function(associatedContext) {
if (err) {
return next(err);
}
associatedContext = associatedContext.pop();
if (associatedContext) { if (associatedContext) {
associatedContext.comments.push(data.id); associatedContext.comments.push(comment.id);
associatedContext.save(function (err) {
if (err) {
return next(err);
}
res.send(true);
});
} }
// NOTE(berks): saveInstance is safe
// it will automatically call onNext with null and onCompleted if
// argument is falsey or has no method save
return saveInstance(associatedContext);
})
.flatMap(function(associatedContext) {
// Find the author of the parent object // Find the author of the parent object
User.findOne({ // if no username
username: associatedContext.author.username var username = associatedContext && associatedContext.author ?
}, function(err, recipient) { associatedContext.author.username :
if (err) { null;
return next(err);
} var query = { where: { username: username } };
return findOneUser(query);
})
// if no user is found we don't want to hit the doOnNext
// filter here will call onCompleted without running through the following
// steps
.filter(function(user) {
return !!user;
})
// if this is called user is guarenteed to exits
// this is a side effect, hence we use do/tap observable methods
.doOnNext(function(user) {
// If the emails of both authors differ, // If the emails of both authors differ,
// only then proceed with email notification // only then proceed with email notification
if ( if (
typeof data.author !== 'undefined' && comment.author &&
data.author.email && comment.author.email &&
typeof recipient !== 'undefined' && user.email &&
recipient.email && (comment.author.email !== user.email)
(data.author.email !== recipient.email)
) { ) {
var transporter = nodemailer.createTransport({ sendMailWhillyNilly({
service: 'Mandrill', to: user.email,
auth: {
user: secrets.mandrill.user,
pass: secrets.mandrill.password
}
});
var mailOptions = {
to: recipient.email,
from: 'Team@freecodecamp.com', from: 'Team@freecodecamp.com',
subject: data.author.username + subject: comment.author.username +
' replied to your post on Camper News', ' replied to your post on Camper News',
text: [ text: [
'Just a quick heads-up: ', 'Just a quick heads-up: ',
data.author.username + ' replied to you on Camper News.', comment.author.username,
' replied to you on Camper News.',
'You can keep this conversation going.', 'You can keep this conversation going.',
'Just head back to the discussion here: ', 'Just head back to the discussion here: ',
'http://freecodecamp.com/stories/' + data.originalStoryLink, 'http://freecodecamp.com/stories/',
comment.originalStoryLink,
'- the Free Code Camp Volunteer Team' '- the Free Code Camp Volunteer Team'
].join('\n') ].join('\n')
};
transporter.sendMail(mailOptions, function (err) {
if (err) {
return err;
}
}); });
} }
}); });
});
} catch (e) {
return next(err);
}
});
} }
}; };

View File

@ -20,11 +20,6 @@ var allFieldGuideIds, allFieldGuideNames, allNonprofitNames,
challengeMapWithNames, allChallengeIds, challengeMapWithNames, allChallengeIds,
challengeMapWithDashedNames; challengeMapWithDashedNames;
/**
* GET /
* Resources.
*/
Array.zip = function(left, right, combinerFunction) { Array.zip = function(left, right, combinerFunction) {
var counter, var counter,
results = []; results = [];
@ -68,7 +63,7 @@ module.exports = {
}, },
unDasherize: function unDasherize(name) { unDasherize: function unDasherize(name) {
return ('' + name).replace(/\-/g, ' '); return ('' + name).replace(/\-/g, ' ').trim();
}, },
getChallengeMapForDisplay: function () { getChallengeMapForDisplay: function () {
@ -200,10 +195,16 @@ module.exports = {
}, },
getURLTitle: function(url, callback) { getURLTitle: function(url, callback) {
(function () { var result = {
var result = {title: '', image: '', url: '', description: ''}; title: '',
request(url, function (error, response, body) { image: '',
if (!error && response.statusCode === 200) { url: '',
description: ''
};
request(url, function(err, response, body) {
if (err || response.statusCode !== 200) {
return callback(new Error('failed'));
}
var $ = cheerio.load(body); var $ = cheerio.load(body);
var metaDescription = $("meta[name='description']"); var metaDescription = $("meta[name='description']");
var metaImage = $("meta[property='og:image']"); var metaImage = $("meta[property='og:image']");
@ -223,11 +224,7 @@ module.exports = {
result.image = urlImage; result.image = urlImage;
result.description = description; result.description = description;
callback(null, result); callback(null, result);
} else {
callback(new Error('failed'));
}
}); });
})();
}, },
getMDNLinks: function(links) { getMDNLinks: function(links) {

View File

@ -1,24 +1,27 @@
var Rx = require('rx'); var Rx = require('rx');
var debug = require('debug')('freecc:rxUtils'); var debug = require('debug')('freecc:rxUtils');
exports.saveUser = function saveUser(user) { exports.saveInstance = function saveInstance(instance) {
return new Rx.Observable.create(function(observer) { return new Rx.Observable.create(function(observer) {
if (!user || typeof user.save !== 'function') { if (!instance || typeof instance.save !== 'function') {
debug('no user or save method'); debug('no instance or save method');
observer.onNext(); observer.onNext();
return observer.onCompleted(); return observer.onCompleted();
} }
user.save(function(err, savedUser) { instance.save(function(err, savedInstance) {
if (err) { if (err) {
return observer.onError(err); return observer.onError(err);
} }
debug('user saved'); debug('instance saved');
observer.onNext(savedUser); observer.onNext(savedInstance);
observer.onCompleted(); observer.onCompleted();
}); });
}); });
}; };
// alias saveInstance
exports.saveUser = exports.saveInstance;
exports.observableQueryFromModel = exports.observableQueryFromModel =
function observableQueryFromModel(Model, method, query) { function observableQueryFromModel(Model, method, query) {
return Rx.Observable.fromNodeCallback(Model[method], Model)(query); return Rx.Observable.fromNodeCallback(Model[method], Model)(query);