diff --git a/common/models/User-Identity.json b/common/models/User-Identity.json
index d63e5c0af3..79c2192f1c 100644
--- a/common/models/User-Identity.json
+++ b/common/models/User-Identity.json
@@ -11,6 +11,13 @@
"foreignKey": "userId"
}
},
- "acls": [],
+ "acls": [
+ {
+ "accessType": "*",
+ "principalType": "ROLE",
+ "principalId": "$everyone",
+ "permission": "DENY"
+ }
+ ],
"methods": []
}
diff --git a/common/models/bonfire.json b/common/models/bonfire.json
index ae5e5db32a..859f0196ed 100644
--- a/common/models/bonfire.json
+++ b/common/models/bonfire.json
@@ -36,7 +36,7 @@
{
"accessType": "READ",
"principalType": "ROLE",
- "principalId": "$authenticated",
+ "principalId": "$everyone",
"permission": "ALLOW"
}
],
diff --git a/common/models/challenge.json b/common/models/challenge.json
index dfc8c9c4a9..f270e7f70b 100644
--- a/common/models/challenge.json
+++ b/common/models/challenge.json
@@ -72,7 +72,7 @@
{
"accessType": "READ",
"principalType": "ROLE",
- "principalId": "$authenticated",
+ "principalId": "$everyone",
"permission": "ALLOW"
}
],
diff --git a/common/models/comment.json b/common/models/comment.json
index da6bb755d1..ec29a05c98 100644
--- a/common/models/comment.json
+++ b/common/models/comment.json
@@ -22,14 +22,15 @@
},
"rank": {
"type": "number",
- "default": "-Infinity"
+ "default": 0
},
"upvotes": {
"type": "array",
"default": []
},
"author": {
- "type": {}
+ "type": {},
+ "default": {}
},
"comments": {
"type": "array",
diff --git a/common/models/field-guide.json b/common/models/field-guide.json
index b8e734247c..be3ae99a25 100644
--- a/common/models/field-guide.json
+++ b/common/models/field-guide.json
@@ -29,7 +29,7 @@
{
"accessType": "READ",
"principalType": "ROLE",
- "principalId": "$authenticated",
+ "principalId": "$everyone",
"permission": "ALLOW"
}
],
diff --git a/common/models/job.json b/common/models/job.json
index 30f981d4df..83d50ebc7b 100644
--- a/common/models/job.json
+++ b/common/models/job.json
@@ -32,7 +32,7 @@
{
"accessType": "READ",
"principalType": "ROLE",
- "principalId": "$authenticated",
+ "principalId": "$everyone",
"permission": "ALLOW"
}
],
diff --git a/common/models/nonprofit.json b/common/models/nonprofit.json
index 35713aa13d..738c43e9ad 100644
--- a/common/models/nonprofit.json
+++ b/common/models/nonprofit.json
@@ -51,7 +51,7 @@
{
"accessType": "READ",
"principalType": "ROLE",
- "principalId": "$authenticated",
+ "principalId": "$everyone",
"permission": "ALLOW"
}
],
diff --git a/public/js/main_0.0.2.js b/public/js/main_0.0.3.js
similarity index 99%
rename from public/js/main_0.0.2.js
rename to public/js/main_0.0.3.js
index faadaed388..20152c48f5 100644
--- a/public/js/main_0.0.2.js
+++ b/public/js/main_0.0.3.js
@@ -319,10 +319,9 @@ $(document).ready(function() {
.fail(function (xhr, textStatus, errorThrown) {
$('#story-submit').bind('click', storySubmitButtonHandler);
})
- .done(function (data, textStatus, xhr) {
- window.location = '/news/' + JSON.parse(data).storyLink;
+ .done(function(data, textStatus, xhr) {
+ window.location = '/stories/' + data.storyLink;
});
-
};
$('#story-submit').on('click', storySubmitButtonHandler);
diff --git a/seed/challenges/basic-html5-and-css.json b/seed/challenges/basic-html5-and-css.json
index 7fdae6da09..c90513cc28 100644
--- a/seed/challenges/basic-html5-and-css.json
+++ b/seed/challenges/basic-html5-and-css.json
@@ -1468,7 +1468,7 @@
"You can create one like this: <input type='text'>
. Note that input
elements are self-closing."
],
"tests": [
- "assert($('input').length > 0, 'Your app should have an text field input element.')"
+ "assert($('input').length > 0, 'Your app should have a text field input element.')"
],
"challengeSeed": [
"",
diff --git a/seed/challenges/bootstrap.json b/seed/challenges/bootstrap.json
index 41a7229731..2fb852ade8 100644
--- a/seed/challenges/bootstrap.json
+++ b/seed/challenges/bootstrap.json
@@ -8,7 +8,7 @@
"dashedName": "waypoint-mobile-responsive-images",
"difficulty": 0.047,
"description": [
- "Now let's go back to our Cat Photo App. This time, we'll style it using the popular Twitter Bootstrap responsive CSS framework. First, add a new image with the src
attribute of \"http://bit.ly/fcc-kittens2\", and add the \"img-responsive\" Bootstrap class to that image.",
+ "Now let's go back to our Cat Photo App. This time, we'll style it using the popular Bootstrap responsive CSS framework. First, add a new image with the src
attribute of \"http://bit.ly/fcc-kittens2\", and add the \"img-responsive\" Bootstrap class to that image.",
"It would be great if the image could be exactly the width of our phone's screen.",
"Fortunately, we have access to a Responsive CSS Framework called Bootstrap. You can add Bootstrap to any app just by including it with <link rel='stylesheet' href='//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css'/>
at the top of your HTML. But we've gone ahead and automatically added it to your Cat Photo App for you.",
"Bootstrap will figure out how wide your screen is and respond by resizing your HTML elements - hence the name Responsive Design
.",
diff --git a/seed/challenges/get-set-for-free-code-camp.json b/seed/challenges/get-set-for-free-code-camp.json
index 9a242fbd9f..ff3c0b8f3f 100644
--- a/seed/challenges/get-set-for-free-code-camp.json
+++ b/seed/challenges/get-set-for-free-code-camp.json
@@ -237,7 +237,7 @@
"challengeSeed": ["127358841"],
"description": [
"One of the best ways to stay motivated when learning to code is to hang out with other campers.",
- "Our chat room and Camper News are great ways to communicate with other campers, but there's no substitute for meeting people in-person.",
+ "Gitter and Camper News are great ways to communicate with other campers, but there's no substitute for meeting people in-person.",
"The easiest way to meet other campers in your city is to join your city's Facebook Group. Click here to view our growing list of local groups.",
"Click the link to your city, then, once Facebook loads, click \"Join group\".",
"Our local groups are new, so if you don't see your city on this list, you should follow the directions to create a Facebook group for your city.",
@@ -256,7 +256,7 @@
"nameEs": "Waypoint: Encuentrate con otros Campers en tu Ciudad",
"descriptionEs": [
"Una de las mejores maneras de mantenerte motivado cuando estás aprendiendo a programar es pasar el rato con otros campers.",
- "Slack y Noticias de Campers son una muy buena forma de comunicarte con otros campers, pero no hay ningún substituto para conocerlos en persona.",
+ "Gitter y Noticias de Campers son una muy buena forma de comunicarte con otros campers, pero no hay ningún substituto para conocerlos en persona.",
"La forma más fácil de encontrarte con otros campers en tu ciudad es unirte al grupo de Facebook de tu ciudad o país. Dale click a here para ver la lista de grupos locales.",
"Dale click al link de tu ciudad o país y una vez que Facebook cargue, dale click a \"Join group\".",
"Nuestros grupos locales son pocos, asi que en caso no veas tu ciudad o país en la lista, solamente sigue las instrucciones para crear un grupo de Facebook para ello.",
@@ -328,7 +328,7 @@
"Cualquier momento en el que te atasques o no sepas que hacer, sigue este simple algoritmo (procedimiento): RSAP (Read, Search, Ask, Post). Que en español vendría a ser Lee, Busca, Pregunta, Publica.",
"Primero, Lee - Lee la documentación o el mensaje de error. El punto fuerte de un buen programador es la habilidad de interpretar y seguir instrucciones.",
"Luego, Busca - Busca en Google. Buenas búsquedas o queries requieren bastante práctica. Cuando búsques en Google, idealmente tienes que incluir el lenguaje o framework que estés usando. También tendrás que limitar los resultados de búsqueda a un periodo reciente.",
- "Ahora, en caso no hayas encontrado la respuesta a tu pregunta, Pregunta - Pregunta a tus amigos. En caso estes en problemas, puedes preguntar a otros campers. Tenemos una sala de chat especificamente para obtener ayuda sobre las herramientas que utilizamos en los desafíos de Free Code Camp. Ingresa a https://freecodecamp.slack.com/messages/help/. Mantén este chat abierto mientras trabajas en los desafíos subsiguientes.",
+ "Ahora, en caso no hayas encontrado la respuesta a tu pregunta, Pregunta - Pregunta a tus amigos. En caso estes en problemas, puedes preguntar a otros campers. Tenemos una sala de chat especificamente para obtener ayuda sobre las herramientas que utilizamos en los desafíos de Free Code Camp. Ingresa a https://gitter.im/FreeCodeCamp/Help. Mantén este chat abierto mientras trabajas en los desafíos subsiguientes.",
"Finalmente, Publica - Publica tu pregunta en Stack Overflow. Antes de hacer esto lee la guía de Stack Overflow para publicar buenas preguntas: http://stackoverflow.com/help/how-to-ask. Tendrás que hacerlo en inglés, en caso no sepas como, pide que te ayuden a traducir tu pregunta en el canal #espanol de Slack.",
"Aquí está nuestra guia detallada en como obtener ayuda: http://freecodecamp.com/field-guide/how-do-i-get-help-when-i-get-stuck.",
"Ahora que tienes en claro el procedimiento a seguir cuando necesites ayuda. ¡Empecémos a programar! Continua con el siguiente desafío."
diff --git a/seed/field-guides.json b/seed/field-guides.json
index e626d3acae..92d9757116 100644
--- a/seed/field-guides.json
+++ b/seed/field-guides.json
@@ -279,7 +279,7 @@
"
", "
It's notoriously difficult to estimate how long building software projects will take, so feel free to ask our volunteer team for help.
", - "You'll continue to meet with your stakeholder at least twice a month in your project's Slack channel.
", - "You should also ask questions in your project's Slack channel as they come up throughout the week, and your stakeholder can answer them asynchronously.
", + "You'll continue to meet with your stakeholder at least twice a month in your project's Gitter or Slack channel.
", + "You should also ask questions in your project's Gitter or Slack channel as they come up throughout the week, and your stakeholder can answer them asynchronously.
", "Getting \"blocked\" on a task can take away your sense of forward momentum, so be sure to proactively seek answers to any ambiguities you encounter.
", "Ultimately, the project will be considered complete once both the stakeholder's needs have been met, and you and your pair are happy with the project. Then you can add it to your portfolio!
", "Here are our recommended ways of collaborating:
", "", "
Free Code Camp should be a harassment-free experience for everyone, regardless of gender, gender identity and expression, age, sexual orientation, disability, physical appearance, body size, race, national origin, or religion (or lack thereof).
", - "We do not tolerate harassment of campers in any form, anywhere on Free Code Camp's online media (Slack, Twitch, etc.) or during pair programming. Harassment includes sexual language and imagery, deliberate intimidation, stalking, unwelcome sexual attention, libel, and any malicious hacking or social engineering.
", + "We do not tolerate harassment of campers in any form, anywhere on Free Code Camp's online media (Gitter, Twitch, etc.) or during pair programming. Harassment includes sexual language and imagery, deliberate intimidation, stalking, unwelcome sexual attention, libel, and any malicious hacking or social engineering.
", "If a camper engages in harassing behavior, our team will take any action we deem appropriate, up to and including banning them from Free Code Camp.
", - "We want everyone to feel safe and respected. If you are being harassed or notice that someone else is being harassed, say something! Message @quincylarson, @terakilobyte and @codenonprofit in Slack (preferably with a screen shot of the offending language) so we can take fast action.
", + "We want everyone to feel safe and respected. If you are being harassed or notice that someone else is being harassed, say something! Message @quincylarson, @terakilobyte and @codenonprofit on Gitter (preferably with a screen shot of the offending language) so we can take fast action.
", "If you have questions about this code of conduct, email us at team@freecodecamp.com.
", "" ] @@ -777,10 +777,10 @@ "", "
We strive to be helpful and transparent in everything we do. We'll do what we can to help you share our community with your audience.
", @@ -838,7 +838,7 @@ "Contributing to our field guide is a great way to establish your history on GitHub, add to your portfolio, and help other campers. If you have a question about JavaScript or programming in general that you'd like us to add to the field guide, here are two ways to get it into the guide:
", "", "
Our translation effort is driven by bilingual campers like you.", - "
If you're able to help us, you can join our Trello board by sending @quincylarson your email address in Slack.
", + "If you're able to help us, you can join our Trello board by sending @quincylarson your email address on Gitter.
", "Translation is an all-or-nothing proposal.", "
We won't be able to add new languages to Free Code Camp until all of our challenges are translated into that language.
", "In addition to translating these initially, we'll also need to maintain the translation as the challenges are gradually updated.
", - "If you're able to help us, you can join our Trello board by sending @quincylarson your email address in Slack.
", + "If you're able to help us, you can join our Trello board by sending @quincylarson your email address on Gitter.
", "" ] }, diff --git a/seed/loopbackMigration.js b/seed/loopbackMigration.js index 02dd993b9c..1a86352e46 100644 --- a/seed/loopbackMigration.js +++ b/seed/loopbackMigration.js @@ -160,28 +160,46 @@ var storyCount = dbObservable }) .count(); +var commentCount = dbObservable + .flatMap(function(db) { + return createQuery(db, 'comments', {}); + }) + .withLatestFrom(dbObservable, function(comments, db) { + return { + comments: comments, + db: db + }; + }) + .flatMap(function(dats) { + return insertMany(dats.db, 'comment', dats.comments, { w: 1 }); + }) + .buffer(20) + .count(); + Rx.Observable.combineLatest( userIdentityCount, userSavesCount, storyCount, - function(userIdentCount, userCount, storyCount) { + commentCount, + function(userIdentCount, userCount, storyCount, commentCount) { return { userIdentCount: userIdentCount * 20, userCount: userCount * 20, - storyCount: storyCount * 20 + storyCount: storyCount * 20, + commentCount: commentCount * 20 }; }) .subscribe( - function(countObj) { - console.log('next'); - count = countObj; - }, - function(err) { - console.error('an error occured', err, err.stack); - }, - function() { + function(countObj) { + console.log('next'); + count = countObj; + }, + function(err) { + console.error('an error occured', err, err.stack); + }, + function() { - console.log('finished with ', count); - process.exit(0); - } -); + console.log('finished with ', count); + process.exit(0); + } + ); diff --git a/server/boot/story.js b/server/boot/story.js index 3dd1019b34..d693b7f48d 100755 --- a/server/boot/story.js +++ b/server/boot/story.js @@ -1,17 +1,84 @@ -var nodemailer = require('nodemailer'), +var Rx = require('rx'), + nodemailer = require('nodemailer'), + assign = require('object.assign'), sanitizeHtml = require('sanitize-html'), moment = require('moment'), mongodb = require('mongodb'), - // debug = require('debug')('freecc:cntr:story'), + debug = require('debug')('freecc:cntr:story'), utils = require('../utils'), + observeMethod = require('../utils/rx').observeMethod, + saveUser = require('../utils/rx').saveUser, + saveInstance = require('../utils/rx').saveInstance, MongoClient = mongodb.MongoClient, 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) { var router = app.loopback.Router(); var User = app.models.User; + var findUserById = observeMethod(User, 'findById'); + var findOneUser = observeMethod(User, 'findOne'); + 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 findCommentById = observeMethod(Comment, 'findById'); router.get('/stories/hotStories', hotJSON); router.get('/stories/comments/:id', comments); @@ -22,46 +89,32 @@ module.exports = function(app) { router.get('/stories/submit/new-story', preSubmit); router.post('/stories/preliminary', newStory); router.post('/stories/', storySubmission); - router.get('/stories/', hot); + router.get('/news/', hot); router.post('/stories/search', getStories); - router.get('/stories/:storyName', returnIndividualStory); + router.get('/news/:storyName', returnIndividualStory); router.post('/stories/upvote/', upvote); + router.get('/stories/:storyName', redirectToNews); 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 redirectToNews(req, res) { + var url = req.originalUrl.replace(/^\/stories/, '/news'); + return res.redirect(url); } function hotJSON(req, res, next) { - Story.find({ + var query = { order: 'timePosted DESC', limit: 1000 - }, function(err, stories) { - if (err) { - return next(err); - } - var foundationDate = 1413298800000; - - var sliceVal = stories.length >= 100 ? 100 : stories.length; - return res.json(stories.map(function(elem) { - return elem; - }).sort(function(a, b) { - return hotRank(b.timePosted - foundationDate, b.rank, b.headline) - - hotRank(a.timePosted - foundationDate, a.rank, a.headline); - }).slice(0, sliceVal)); - - }); + }; + 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) { @@ -78,32 +131,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) { - var data = req.query; - var cleanData = sanitizeHtml(data.url, { - allowedTags: [], - allowedAttributes: [] - }).replace(/";/g, '"'); - if (data.url.replace(/&/g, '&') !== cleanData) { + var cleanedData = cleanData(data.url); + if (data.url.replace(/&/g, '&') !== cleanedData) { req.flash('errors', { msg: 'The data for this post is malformed' }); @@ -125,64 +157,54 @@ module.exports = function(app) { }); } - function returnIndividualStory(req, res, next) { var dashedName = req.params.storyName; + var storyName = unDasherize(dashedName); - var storyName = dashedName.replace(/\-/g, ' ').trim(); - - Story.find({ where: { storyLink: storyName } }, function(err, story) { - if (err) { - return next(err); - } - - - if (story.length < 1) { - req.flash('errors', { - msg: "404: We couldn't find a story with that name. " + - 'Please double check the name.' - }); - - return res.redirect('/stories/'); - } - - story = story.pop(); - var dashedNameFull = story.storyLink.toLowerCase() - .replace(/\s+/g, ' ') - .replace(/\s/g, '-'); - if (dashedNameFull !== dashedName) { - return res.redirect('../stories/' + dashedNameFull); - } - - var userVoted = false; - try { - var votedObj = story.upVotes.filter(function(a) { - return a['upVotedByUsername'] === req.user['profile']['username']; - }); - if (votedObj.length > 0) { - userVoted = true; + 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'); } - } catch(e) { - userVoted = false; - } - 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 - }); - }); + + 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, + 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 + }); + }, + next + ); } function getStories(req, res, next) { @@ -228,57 +250,52 @@ module.exports = function(app) { } function upvote(req, res, next) { - var data = req.body.data; - Story.find({ where: { id: data.id } }, function(err, story) { - if (err) { - return next(err); - } - story = story.pop(); - story.rank++; - story.upVotes.push( - { + var id = req.body.data.id; + 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(Date.now()); + } + return saveUser(user); + }) + .flatMap(function() { + req.user.progressTimestamps.push(Date.now()); + return saveUser(req.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 ); - story.markModified('rank'); - story.save(); - // NOTE(Berks): This logic is full of wholes and race conditions - // this could be the source of many 'can't set headers after - // 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); - user.save(function (err) { - req.user.save(function (err) { - if (err) { return next(err); } - }); - req.user.progressTimestamps.push(Date.now() || 0); - if (err) { - return next(err); - } - }); - } - ); - return res.send(story); - }); } function comments(req, res, next) { - var data = req.params.id; - Comment.find( - { where: { id: data } }, - function(err, comment) { - if (err) { - return next(err); - } - comment = comment.pop(); - return res.send(comment); - }); + var id = req.params.id; + findCommentById(id).subscribe( + function(comment) { + res.send(comment); + }, + next + ); } function newStory(req, res, next) { @@ -286,10 +303,8 @@ module.exports = function(app) { return next(new Error('Must be logged in')); } var url = req.body.data.url; - var cleanURL = sanitizeHtml(url, { - allowedTags: [], - allowedAttributes: [] - }).replace(/"/g, '"'); + var cleanURL = cleanData(url); + if (cleanURL !== url) { req.flash('errors', { msg: "The URL you submitted doesn't appear valid" @@ -303,44 +318,46 @@ module.exports = function(app) { if (url.search(/^https?:\/\//g) === -1) { 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) { - if (err) { - res.json({ + findStory({ where: { link: url } }) + .map(function(stories) { + if (stories.length) { + return { + alreadyPosted: true, + storyURL: '/stories/' + stories.pop().storyLink + }; + } + return { alreadyPosted: false, - storyURL: url, - storyTitle: '', - storyImage: '', - storyMetaDescription: '' - }); - } else { - res.json({ - alreadyPosted: false, - storyURL: url, - storyTitle: story.title, - storyImage: story.image, - storyMetaDescription: story.description - }); - } - } + 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) { @@ -360,66 +377,60 @@ module.exports = function(app) { link = 'http://' + link; } - Story.count({ + var query = { storyLink: { like: ('^' + storyLink + '(?: [0-9]+)?$'), options: 'i' } - }, function (err, storyCount) { - if (err) { - return next(err); - } + }; - // if duplicate storyLink add unique number - storyLink = (storyCount === 0) ? storyLink : storyLink + ' ' + storyCount; + 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 story = new Story({ - headline: sanitizeHtml(data.headline, { - allowedTags: [], - allowedAttributes: [] - }).replace(/"/g, '"'), - timePosted: Date.now(), - link: link, - description: sanitizeHtml(data.description, { - allowedTags: [], - allowedAttributes: [] - }).replace(/"/g, '"'), - 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 - }); - story.save(function (err) { - if (err) { - return next(err); + var link = data.link; + if (link.search(/^https?:\/\//g) === -1) { + link = 'http://' + link; } - 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() - })); + 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 }); + return saveInstance(newStory); }); - }); + + 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) { @@ -427,11 +438,8 @@ module.exports = function(app) { if (!req.user) { return next(new Error('Not authorized')); } - var sanitizedBody = sanitizeHtml(data.body, - { - allowedTags: [], - allowedAttributes: [] - }).replace(/"/g, '"'); + var sanitizedBody = cleanData(data.body); + if (data.body !== sanitizedBody) { req.flash('errors', { msg: 'HTML is not allowed' @@ -456,7 +464,13 @@ module.exports = function(app) { commentOn: Date.now() }); - commentSave(comment, Story, res, next); + commentSave(comment, findStoryById).subscribe( + function() {}, + next, + function() { + res.send(true); + } + ); } function commentOnCommentSubmit(req, res, next) { @@ -465,13 +479,7 @@ module.exports = function(app) { return next(new Error('Not authorized')); } - var sanitizedBody = sanitizeHtml( - data.body, - { - allowedTags: [], - allowedAttributes: [] - } - ).replace(/"/g, '"'); + var sanitizedBody = cleanData(data.body); if (data.body !== sanitizedBody) { req.flash('errors', { @@ -497,119 +505,101 @@ module.exports = function(app) { topLevel: false, commentOn: Date.now() }); - commentSave(comment, Comment, res, next); + commentSave(comment, findCommentById).subscribe( + function() {}, + next, + function() { + res.send(true); + } + ); } function commentEdit(req, res, next) { - - Comment.find({ where: { id: req.params.id } }, function(err, cmt) { - if (err) { - return next(err); - } - cmt = cmt.pop(); - - if (!req.user && cmt.author.userId !== req.user.id) { - return next(new Error('Not authorized')); - } - - var sanitizedBody = sanitizeHtml(req.body.body, { - allowedTags: [], - allowedAttributes: [] - }).replace(/"/g, '"'); - if (req.body.body !== sanitizedBody) { - req.flash('errors', { - 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); + findCommentById(req.params.id) + .doOnNext(function(comment) { + if (!req.user && comment.author.userId !== req.user.id) { + throw new Error('Not authorized'); } - res.send(true); - }); - - }); - + }) + .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 + ); } - function commentSave(comment, Context, res, next) { - comment.save(function(err, data) { - if (err) { - return next(err); - } - try { + function commentSave(comment, findContextById) { + return saveInstance(comment) + .flatMap(function(comment) { // Based on the context retrieve the parent // object of the comment (Story/Comment) - Context.find({ - where: { id: data.associatedPost } - }, function (err, associatedContext) { - if (err) { - return next(err); - } - associatedContext = associatedContext.pop(); - if (associatedContext) { - associatedContext.comments.push(data.id); - associatedContext.save(function (err) { - if (err) { - return next(err); - } - res.send(true); - }); - } - // Find the author of the parent object - User.findOne({ - username: associatedContext.author.username - }, function(err, recipient) { - if (err) { - return next(err); - } - // If the emails of both authors differ, - // only then proceed with email notification - if ( - typeof data.author !== 'undefined' && - data.author.email && - typeof recipient !== 'undefined' && - recipient.email && - (data.author.email !== recipient.email) - ) { - var transporter = nodemailer.createTransport({ - service: 'Mandrill', - auth: { - user: secrets.mandrill.user, - pass: secrets.mandrill.password - } - }); + 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 mailOptions = { - to: recipient.email, - from: 'Team@freecodecamp.com', - subject: data.author.username + - ' replied to your post on Camper News', - text: [ - 'Just a quick heads-up: ', - data.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/' + data.originalStoryLink, - '- the Free Code Camp Volunteer Team' - ].join('\n') - }; - - transporter.sendMail(mailOptions, function (err) { - if (err) { - return 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, + // 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') }); - }); - } catch (e) { - return next(err); - } - }); + } + }); } }; diff --git a/server/utils/index.js b/server/utils/index.js index b49a4579f7..ec64435a5f 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -20,11 +20,6 @@ var allFieldGuideIds, allFieldGuideNames, allNonprofitNames, challengeMapWithNames, allChallengeIds, challengeMapWithDashedNames; -/** - * GET / - * Resources. - */ - Array.zip = function(left, right, combinerFunction) { var counter, results = []; @@ -68,7 +63,7 @@ module.exports = { }, unDasherize: function unDasherize(name) { - return ('' + name).replace(/\-/g, ' '); + return ('' + name).replace(/\-/g, ' ').trim(); }, getChallengeMapForDisplay: function () { @@ -199,35 +194,37 @@ module.exports = { return process.env.NODE_ENV; }, - getURLTitle: function (url, callback) { - (function () { - var result = {title: '', image: '', url: '', description: ''}; - request(url, function (error, response, body) { - if (!error && response.statusCode === 200) { - var $ = cheerio.load(body); - var metaDescription = $("meta[name='description']"); - var metaImage = $("meta[property='og:image']"); - var urlImage = metaImage.attr('content') ? - metaImage.attr('content') : - ''; + getURLTitle: function(url, callback) { + var result = { + title: '', + image: '', + url: '', + description: '' + }; + request(url, function(err, response, body) { + if (err || response.statusCode !== 200) { + return callback(new Error('failed')); + } + var $ = cheerio.load(body); + var metaDescription = $("meta[name='description']"); + var metaImage = $("meta[property='og:image']"); + var urlImage = metaImage.attr('content') ? + metaImage.attr('content') : + ''; - var metaTitle = $('title'); - var description = metaDescription.attr('content') ? - metaDescription.attr('content') : - ''; + var metaTitle = $('title'); + var description = metaDescription.attr('content') ? + metaDescription.attr('content') : + ''; - result.title = metaTitle.text().length < 90 ? - metaTitle.text() : - metaTitle.text().slice(0, 87) + '...'; + result.title = metaTitle.text().length < 90 ? + metaTitle.text() : + metaTitle.text().slice(0, 87) + '...'; - result.image = urlImage; - result.description = description; - callback(null, result); - } else { - callback(new Error('failed')); - } - }); - })(); + result.image = urlImage; + result.description = description; + callback(null, result); + }); }, getMDNLinks: function(links) { diff --git a/server/utils/rx.js b/server/utils/rx.js index 7e98aa486b..8088e163e0 100644 --- a/server/utils/rx.js +++ b/server/utils/rx.js @@ -1,24 +1,27 @@ var Rx = require('rx'); var debug = require('debug')('freecc:rxUtils'); -exports.saveUser = function saveUser(user) { +exports.saveInstance = function saveInstance(instance) { return new Rx.Observable.create(function(observer) { - if (!user || typeof user.save !== 'function') { - debug('no user or save method'); + if (!instance || typeof instance.save !== 'function') { + debug('no instance or save method'); observer.onNext(); return observer.onCompleted(); } - user.save(function(err, savedUser) { + instance.save(function(err, savedInstance) { if (err) { return observer.onError(err); } - debug('user saved'); - observer.onNext(savedUser); + debug('instance saved'); + observer.onNext(savedInstance); observer.onCompleted(); }); }); }; +// alias saveInstance +exports.saveUser = exports.saveInstance; + exports.observableQueryFromModel = function observableQueryFromModel(Model, method, query) { return Rx.Observable.fromNodeCallback(Model[method], Model)(query); diff --git a/server/views/partials/navbar.jade b/server/views/partials/navbar.jade index 3f7475be42..c0698dd287 100644 --- a/server/views/partials/navbar.jade +++ b/server/views/partials/navbar.jade @@ -16,7 +16,7 @@ nav.navbar.navbar-default.navbar-fixed-top.nav-height li a(href='//gitter.im/FreeCodeCamp/FreeCodeCamp', target='_blank') Chat li - a(href='/stories') News + a(href='/news') News li a(href='/field-guide') Guide if !user diff --git a/server/views/partials/universal-head.jade b/server/views/partials/universal-head.jade index 47bc657df6..c31b46da4c 100644 --- a/server/views/partials/universal-head.jade +++ b/server/views/partials/universal-head.jade @@ -32,7 +32,7 @@ script. window.moment || document.write('