freeCodeCamp/server/boot/story.js

598 lines
16 KiB
JavaScript
Raw Normal View History

2015-06-25 22:03:46 +00:00
var Rx = require('rx'),
nodemailer = require('nodemailer'),
assign = require('object.assign'),
2015-06-01 23:23:53 +00:00
sanitizeHtml = require('sanitize-html'),
moment = require('moment'),
mongodb = require('mongodb'),
// debug = require('debug')('freecc:cntr:story'),
2015-06-04 00:14:45 +00:00
utils = require('../utils'),
2015-06-25 22:03:46 +00:00
observeMethod = require('../utils/rx').observeMethod,
saveUser = require('../utils/rx').saveUser,
saveInstance = require('../utils/rx').saveInstance,
2015-06-01 23:23:53 +00:00
MongoClient = mongodb.MongoClient,
secrets = require('../../config/secrets');
2015-06-25 22:03:46 +00:00
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) {
var router = app.loopback.Router();
var User = app.models.User;
2015-06-25 22:03:46 +00:00
var findUserById = observeMethod(User, 'findById');
var findOneUser = observeMethod(User, 'findOne');
var Story = app.models.Story;
2015-06-25 22:03:46 +00:00
var findStory = observeMethod(Story, 'find');
var findOneStory = observeMethod(Story, 'findOne');
var findStoryById = observeMethod(Story, 'findById');
var countStories = observeMethod(Story, 'count');
2015-06-24 14:15:39 +00:00
var Comment = app.models.Comment;
2015-06-25 22:03:46 +00:00
var findCommentById = observeMethod(Comment, 'findById');
router.get('/stories/hotStories', hotJSON);
router.get('/stories/comments/:id', comments);
router.post('/stories/comment/', commentSubmit);
router.post('/stories/comment/:id/comment', commentOnCommentSubmit);
router.put('/stories/comment/:id/edit', commentEdit);
router.get('/stories/submit', submitNew);
router.get('/stories/submit/new-story', preSubmit);
router.post('/stories/preliminary', newStory);
router.post('/stories/', storySubmission);
router.get('/stories/', hot);
router.post('/stories/search', getStories);
router.get('/stories/:storyName', returnIndividualStory);
router.post('/stories/upvote/', upvote);
app.use(router);
function hotJSON(req, res, next) {
2015-06-25 22:03:46 +00:00
var query = {
2015-06-23 23:28:16 +00:00
order: 'timePosted DESC',
limit: 1000
2015-06-25 22:03:46 +00:00
};
findStory(query).subscribe(
function(stories) {
var sliceVal = stories.length >= 100 ? 100 : stories.length;
var data = stories.sort(sortByRank).slice(0, sliceVal);
res.json(data);
},
next
);
}
function hot(req, res) {
return res.render('stories/index', {
title: 'Hot stories currently trending on Camper News',
page: 'hot'
});
}
function submitNew(req, res) {
return res.render('stories/index', {
title: 'Submit a new story to Camper News',
page: 'submit'
});
}
function preSubmit(req, res) {
var data = req.query;
2015-06-25 22:03:46 +00:00
var cleanedData = cleanData(data.url);
2015-06-25 22:03:46 +00:00
if (data.url.replace(/&/g, '&') !== cleanedData) {
req.flash('errors', {
msg: 'The data for this post is malformed'
});
return res.render('stories/index', {
page: 'stories/submit'
});
}
var title = data.title || '';
var image = data.image || '';
var description = data.description || '';
return res.render('stories/index', {
title: 'Confirm your Camper News story submission',
page: 'storySubmission',
storyURL: data.url,
storyTitle: title,
storyImage: image,
storyMetaDescription: description
});
}
function returnIndividualStory(req, res, next) {
var dashedName = req.params.storyName;
2015-06-25 22:03:46 +00:00
var storyName = unDasherize(dashedName);
2015-06-25 22:03:46 +00:00
findOneStory({ where: { storyLink: storyName } }).subscribe(
function(story) {
if (!story) {
req.flash('errors', {
msg: "404: We couldn't find a story with that name. " +
'Please double check the name.'
});
2015-06-25 22:03:46 +00:00
var dashedNameFull = story.storyLink.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/\s/g, '-');
2015-06-25 22:03:46 +00:00
if (dashedNameFull !== dashedName) {
return res.redirect('../stories/' + dashedNameFull);
}
return res.redirect('/stories/');
}
2015-06-26 06:00:49 +00:00
var username = req.user ? req.user.username : '';
2015-06-25 22:03:46 +00:00
// true if any of votes are made by user
var userVoted = story.upVotes.some(function(upvote) {
2015-06-26 06:00:49 +00:00
return upvote.upVotedByUsername === username;
});
2015-06-25 22:03:46 +00:00
res.render('stories/index', {
title: story.headline,
link: story.link,
originalStoryLink: dashedName,
originalStoryAuthorEmail: story.author.email || '',
author: story.author,
description: story.description,
rank: story.upVotes.length,
upVotes: story.upVotes,
comments: story.comments,
id: story.id,
timeAgo: moment(story.timePosted).fromNow(),
image: story.image,
page: 'show',
storyMetaDescription: story.metaDescription,
hasUserVoted: userVoted
});
2015-06-25 22:03:46 +00:00
},
next
);
}
function getStories(req, res, next) {
MongoClient.connect(secrets.db, function(err, database) {
if (err) {
2015-03-25 17:28:04 +00:00
return next(err);
}
database.collection('stories').find({
'$text': {
'$search': req.body.data ? req.body.data.searchValue : ''
}
}, {
headline: 1,
timePosted: 1,
link: 1,
description: 1,
rank: 1,
upVotes: 1,
author: 1,
comments: 1,
image: 1,
storyLink: 1,
metaDescription: 1,
textScore: {
$meta: 'textScore'
}
}, {
sort: {
textScore: {
$meta: 'textScore'
}
}
}).toArray(function(err, items) {
if (err) {
return next(err);
}
if (items !== null && items.length !== 0) {
return res.json(items);
}
return res.sendStatus(404);
});
});
}
function upvote(req, res, next) {
2015-06-25 22:03:46 +00:00
var id = req.body.data.id;
var savedStory = findStoryById(id)
.flatMap(function(story) {
story.rank += 1;
story.upVotes.push({
upVotedBy: req.user.id,
upVotedByUsername: req.user.username
});
return saveInstance(story);
})
.shareReplay();
savedStory.flatMap(function(story) {
// find story author
return findUserById(story.author.userId);
})
.flatMap(function(user) {
// if user deletes account then this will not exist
if (user) {
user.progressTimestamps.push(Date.now());
}
2015-06-25 22:03:46 +00:00
return saveUser(user);
})
.flatMap(function() {
req.user.progressTimestamps.push(Date.now());
return saveUser(req.user);
})
.flatMap(savedStory)
.subscribe(
function(story) {
return res.send(story);
},
next
);
}
function comments(req, res, next) {
2015-06-25 22:03:46 +00:00
var id = req.params.id;
findCommentById(id).subscribe(
function(comment) {
res.send(comment);
},
next
);
}
function newStory(req, res, next) {
if (!req.user) {
return next(new Error('Must be logged in'));
}
var url = req.body.data.url;
2015-06-25 22:03:46 +00:00
var cleanURL = cleanData(url);
if (cleanURL !== url) {
req.flash('errors', {
msg: "The URL you submitted doesn't appear valid"
});
return res.json({
alreadyPosted: true,
storyURL: '/stories/submit'
});
}
if (url.search(/^https?:\/\//g) === -1) {
url = 'http://' + url;
}
2015-06-25 22:03:46 +00:00
findStory({ where: { link: url } })
.map(function(stories) {
if (stories.length) {
return {
alreadyPosted: true,
2015-06-25 22:03:46 +00:00
storyURL: '/stories/' + stories.pop().storyLink
};
}
2015-06-25 22:03:46 +00:00
return {
alreadyPosted: false,
2015-06-25 22:03:46 +00:00
storyURL: url
};
})
.flatMap(function(data) {
if (data.alreadyPosted) {
return Rx.Observable.just(data);
}
return Rx.Observable.fromNodeCallback(getURLTitle)(data.storyURL)
.map(function(story) {
return {
alreadyPosted: false,
storyURL: data.storyURL,
storyTitle: story.title,
storyImage: story.image,
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
);
}
2015-06-01 23:23:53 +00:00
function storySubmission(req, res, next) {
var data = req.body.data;
if (!req.user) {
return next(new Error('Not authorized'));
2015-04-20 04:28:55 +00:00
}
var storyLink = data.headline
.replace(/[^a-z0-9\s]/gi, '')
.replace(/\s+/g, ' ')
.toLowerCase()
.trim();
2015-04-20 04:28:55 +00:00
var link = data.link;
2015-04-20 04:28:55 +00:00
if (link.search(/^https?:\/\//g) === -1) {
link = 'http://' + link;
}
2015-06-25 22:03:46 +00:00
var query = {
2015-06-23 23:28:16 +00:00
storyLink: {
like: ('^' + storyLink + '(?: [0-9]+)?$'),
options: 'i'
}
2015-06-25 22:03:46 +00:00
};
var savedStory = countStories(query)
.flatMap(function(storyCount) {
// if duplicate storyLink add unique number
storyLink = (storyCount === 0) ?
storyLink :
storyLink + ' ' + storyCount;
var link = data.link;
if (link.search(/^https?:\/\//g) === -1) {
link = 'http://' + link;
}
2015-06-25 22:03:46 +00:00
var newStory = new Story({
headline: cleanData(data.headline),
timePosted: Date.now(),
link: link,
description: cleanData(data.description),
rank: 1,
upVotes: [({
upVotedBy: req.user.id,
upVotedByUsername: req.user.username
})],
author: {
picture: req.user.picture,
userId: req.user.id,
username: req.user.username,
email: req.user.email
},
comments: [],
image: data.image,
storyLink: storyLink,
metaDescription: data.storyMetaDescription,
originalStoryAuthorEmail: req.user.email
});
2015-06-25 22:03:46 +00:00
return saveInstance(newStory);
});
2015-06-25 22:03:46 +00:00
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) {
var data = req.body.data;
if (!req.user) {
return next(new Error('Not authorized'));
}
2015-06-25 22:03:46 +00:00
var sanitizedBody = cleanData(data.body);
if (data.body !== sanitizedBody) {
req.flash('errors', {
msg: 'HTML is not allowed'
});
return res.send(true);
}
var comment = new Comment({
associatedPost: data.associatedPost,
originalStoryLink: data.originalStoryLink,
originalStoryAuthorEmail: data.originalStoryAuthorEmail,
body: sanitizedBody,
rank: 0,
upvotes: 0,
author: {
picture: req.user.picture,
userId: req.user.id,
username: req.user.username,
email: req.user.email
},
comments: [],
topLevel: true,
commentOn: Date.now()
2015-04-20 04:28:55 +00:00
});
2015-06-25 22:03:46 +00:00
commentSave(comment, findStoryById).subscribe(
function() {},
next,
function() {
res.send(true);
}
);
}
function commentOnCommentSubmit(req, res, next) {
var data = req.body.data;
if (!req.user) {
2015-04-20 02:22:11 +00:00
return next(new Error('Not authorized'));
}
2015-06-25 22:03:46 +00:00
var sanitizedBody = cleanData(data.body);
if (data.body !== sanitizedBody) {
2015-04-20 02:22:11 +00:00
req.flash('errors', {
msg: 'HTML is not allowed'
});
2015-04-20 02:22:11 +00:00
return res.send(true);
}
2015-04-20 02:22:11 +00:00
var comment = new Comment({
associatedPost: data.associatedPost,
body: sanitizedBody,
rank: 0,
upvotes: 0,
originalStoryLink: data.originalStoryLink,
originalStoryAuthorEmail: data.originalStoryAuthorEmail,
author: {
picture: req.user.picture,
userId: req.user.id,
username: req.user.username,
email: req.user.email
},
comments: [],
topLevel: false,
commentOn: Date.now()
});
2015-06-25 22:03:46 +00:00
commentSave(comment, findCommentById).subscribe(
function() {},
next,
function() {
res.send(true);
}
);
}
function commentEdit(req, res, next) {
2015-06-25 22:03:46 +00:00
findCommentById(req.params.id)
.doOnNext(function(comment) {
if (!req.user && comment.author.userId !== req.user.id) {
throw new Error('Not authorized');
}
2015-06-25 22:03:46 +00:00
})
.flatMap(function(comment) {
var sanitizedBody = cleanData(req.body.body);
if (req.body.body !== sanitizedBody) {
req.flash('errors', {
msg: 'HTML is not allowed'
});
}
comment.body = sanitizedBody;
comment.commentOn = Date.now();
return saveInstance(comment);
})
.subscribe(
function() {
res.send(true);
},
next
);
}
2015-06-25 22:03:46 +00:00
function commentSave(comment, findContextById) {
return saveInstance(comment)
.flatMap(function(comment) {
// Based on the context retrieve the parent
// object of the comment (Story/Comment)
2015-06-25 22:03:46 +00:00
return findContextById(comment.associatedPost);
})
.flatMap(function(associatedContext) {
if (associatedContext) {
associatedContext.comments.push(comment.id);
}
// 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
// if no username
var username = associatedContext && associatedContext.author ?
associatedContext.author.username :
null;
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,
// only then proceed with email notification
if (
comment.author &&
comment.author.email &&
user.email &&
(comment.author.email !== user.email)
) {
sendMailWhillyNilly({
to: user.email,
from: 'Team@freecodecamp.com',
subject: comment.author.username +
' replied to your post on Camper News',
text: [
'Just a quick heads-up: ',
comment.author.username,
' replied to you on Camper News.',
'You can keep this conversation going.',
'Just head back to the discussion here: ',
'http://freecodecamp.com/stories/',
comment.originalStoryLink,
'- the Free Code Camp Volunteer Team'
].join('\n')
});
2015-06-25 22:03:46 +00:00
}
});
}
};