var Rx = require('rx'), assign = require('object.assign'), sanitizeHtml = require('sanitize-html'), moment = require('moment'), debug = require('debug')('freecc:cntr:story'), utils = require('../utils'), observeMethod = require('../utils/rx').observeMethod, saveUser = require('../utils/rx').saveUser, saveInstance = require('../utils/rx').saveInstance, validator = require('validator'); import { ifNoUser401, ifNoUserRedirectTo } from '../utils/middleware'; const foundationDate = 1413298800000; const time48Hours = 172800000; const unDasherize = utils.unDasherize; const dasherize = utils.dasherize; const getURLTitle = utils.getURLTitle; const sendNonUserToNews = ifNoUserRedirectTo('/news'); 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; var findUserById = observeMethod(User, 'findById'); 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'); router.post('/news/userstories', userStories); router.get('/news/hot', hotJSON); router.get('/news/feed', RSSFeed); router.get('/stories/hotStories', hotJSON); router.get( '/stories/submit', sendNonUserToNews, submitNew ); router.get( '/stories/submit/new-story', sendNonUserToNews, preSubmit ); router.post('/stories/preliminary', ifNoUser401, newStory); router.post('/stories/', ifNoUser401, storySubmission); router.get('/news/', hot); router.post('/stories/search', getStories); router.get('/news/:storyName', returnIndividualStory); router.post('/stories/upvote/', ifNoUser401, upvote); router.get('/stories/:storyName', redirectToNews); app.use(router); function redirectToNews(req, res) { var url = req.originalUrl.replace(/^\/stories/, '/news'); return res.redirect(url); } function hotJSON(req, res, next) { var query = { order: 'timePosted DESC', limit: 1000 }; 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 RSSFeed(req, res, next) { var query = { order: 'timePosted DESC', limit: 1000 }; findStory(query).subscribe( function(stories) { var sliceVal = stories.length >= 100 ? 100 : stories.length; var data = stories.sort(sortByRank).slice(0, sliceVal); res.set('Content-Type', 'text/xml'); res.render('feed', { title: 'FreeCodeCamp Camper News RSS Feed', description: 'RSS Feed for FreeCodeCamp Top 100 Hot Camper News', url: 'http://www.freecodecamp.com/news', FeedPosts: data }); }, next ); } function hot(req, res) { return res.render('stories/index', { title: 'Top Stories on Camper News', page: 'hot' }); } function submitNew(req, res) { if (!req.user.isGithubCool) { req.flash('errors', { msg: 'You must link GitHub with your account before you can post' + ' on Camper News.' }); return res.redirect('/news'); } return res.render('stories/index', { title: 'Submit a new story to Camper News', page: 'submit' }); } function preSubmit(req, res) { var data = req.query; if (typeof data.url !== 'string') { req.flash('errors', { msg: 'No URL supplied with story' }); return res.redirect('/news'); } var cleanedData = cleanData(data.url); 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; var storyName = unDasherize(dashedName); 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.' }); return res.redirect('/news'); } var dashedNameFull = story.storyLink.toLowerCase() .replace(/\s+/g, ' ') .replace(/\s/g, '-'); if (dashedNameFull !== dashedName) { return res.redirect('../stories/' + dashedNameFull); } var username = req.user ? req.user.username : ''; // true if any of votes are made by user var userVoted = story.upVotes.some(function(upvote) { return upvote.upVotedByUsername === username; }); res.render('stories/index', { title: story.headline, link: story.link, originalStoryLink: dashedName, author: story.author, rank: story.upVotes.length, upVotes: story.upVotes, id: story.id, timeAgo: moment(story.timePosted).fromNow(), image: story.image, page: 'show', storyMetaDescription: story.metaDescription, hasUserVoted: userVoted }); }, next ); } function userStories({ body: { search = '' } = {} }, res, next) { if (!search || typeof search !== 'string') { return res.sendStatus(404); } return app.dataSources.db.connector .collection('story') .find({ 'author.username': search.toLowerCase().replace('$', '') }) .toArray(function(err, items) { if (err) { return next(err); } if (items && items.length !== 0) { return res.json(items.sort(sortByRank)); } return res.sendStatus(404); }); } function getStories({ body: { search = '' } = {} }, res, next) { if (!search || typeof search !== 'string') { return res.sendStatus(404); } const query = { '$text': { // protect against NoSQL injection '$search': search.replace('$', '') } }; const fields = { headline: 1, timePosted: 1, link: 1, description: 1, rank: 1, upVotes: 1, author: 1, image: 1, storyLink: 1, metaDescription: 1, textScore: { $meta: 'textScore' } }; const options = { sort: { textScore: { $meta: 'textScore' } } }; return app.dataSources.db.connector .collection('story') .find(query, fields, options) .toArray(function(err, items) { if (err) { return next(err); } if (items && items.length !== 0) { return res.json(items); } return res.sendStatus(404); }); } function upvote(req, res, next) { const { id } = req.body; var story$ = findStoryById(id).shareReplay(); story$.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({ timestamp: Date.now() }); } return saveUser(user); }) .flatMap(function() { return story$; }) .flatMap(function(story) { debug('upvoting'); story.rank += 1; story.upVotes.push({ upVotedBy: req.user.id, upVotedByUsername: req.user.username }); return saveInstance(story); }) .subscribe( function(story) { return res.send(story); }, next ); } function newStory(req, res, next) { if (!req.user.isGithubCool) { req.flash('errors', { msg: 'You must authenticate with Github to post to Camper News' }); return res.redirect('/news'); } var url = req.body.data.url; if (!validator.isURL(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; } findStory({ where: { link: url } }) .map(function(stories) { if (stories.length) { return { alreadyPosted: true, storyURL: '/stories/' + stories.pop().storyLink }; } return { alreadyPosted: false, 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 ); } function storySubmission(req, res, next) { if (req.user.isBanned) { return res.json({ isBanned: true }); } var data = req.body.data; var storyLink = data.headline .replace(/[^a-z0-9\s]/gi, '') .replace(/\s+/g, ' ') .toLowerCase() .trim(); var link = data.link; if (link.search(/^https?:\/\//g) === -1) { link = 'http://' + link; } var query = { storyLink: { like: ('^' + storyLink + '(?: [0-9]+)?$'), options: 'i' } }; 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; } 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 }, image: data.image, storyLink: storyLink, metaDescription: data.storyMetaDescription }); return saveInstance(newStory); }); req.user.progressTimestamps.push({ timestamp: Date.now() }); return saveUser(req.user) .flatMap(savedStory) .subscribe( function(story) { res.json({ storyLink: dasherize(story.storyLink) }); }, next ); } };