476 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			476 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
| 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
 | |
|       );
 | |
|   }
 | |
| };
 |