diff --git a/app.js b/app.js index 12269cf30d9..30bd4d17c90 100644 --- a/app.js +++ b/app.js @@ -102,6 +102,7 @@ app.use(flash()); app.disable('x-powered-by'); app.use(helmet.xssFilter()); +app.use(helmet.noSniff()); app.use(helmet.xframe()); var trusted = [ @@ -126,7 +127,8 @@ var trusted = [ 'localhost:3000', 'ws://localhost:3000/', 'http://localhost:3000', - '*.ionicframework.com' + '*.ionicframework.com', + 'https://syndication.twitter.com' ]; debug(trusted); @@ -256,6 +258,9 @@ app.get( ); app.all('/account', passportConf.isAuthenticated); app.get('/account/api', userController.getAccountAngular); +// Unique Check API route +app.get('/api/checkUniqueUsername/:username', userController.checkUniqueUsername); +app.get('/api/checkUniqueEmail/:email', userController.checkUniqueEmail); app.get('/account', userController.getAccount); app.post('/account/profile', userController.postUpdateProfile); app.post('/account/password', userController.postUpdatePassword); @@ -293,13 +298,11 @@ app.get('/auth/twitter', passport.authenticate('twitter')); app.get( '/auth/twitter/callback', passport.authenticate('twitter', { - successRedirect: '/auth/twitter/middle', + successRedirect: '/', failureRedirect: '/login' }) ); -app.get('/auth/twitter/middle', passportConf.hasEmail); - app.get( '/auth/linkedin', passport.authenticate('linkedin', { diff --git a/config/passport.js b/config/passport.js index 34aec6fd557..197bbfb0f64 100644 --- a/config/passport.js +++ b/config/passport.js @@ -9,27 +9,35 @@ var _ = require('lodash'), OAuthStrategy = require('passport-oauth').OAuthStrategy, OAuth2Strategy = require('passport-oauth').OAuth2Strategy, User = require('../models/User'), + nodemailer = require('nodemailer'), secrets = require('./secrets'); -// Login Required middleware. -module.exports = { - isAuthenticated: isAuthenticated, - isAuthorized: isAuthorized, - hasEmail: hasEmail -}; passport.serializeUser(function(user, done) { done(null, user.id); }); passport.deserializeUser(function(id, done) { - User.findOne({ - _id: id - }, '-password', function(err, user) { + User.findById(id, function(err, user) { done(err, user); }); }); +// Sign in using Email and Password. + +passport.use(new LocalStrategy({ usernameField: 'email' }, function(email, password, done) { + User.findOne({ email: email }, function(err, user) { + if (!user) return done(null, false, { message: 'Email ' + email + ' not found'}); + user.comparePassword(password, function(err, isMatch) { + if (isMatch) { + return done(null, user); + } else { + return done(null, false, { message: 'Invalid email or password.' }); + } + }); + }); +})); + /** * OAuth Strategy Overview * @@ -45,137 +53,69 @@ passport.deserializeUser(function(id, done) { * - Else create a new account. */ -// Sign in with Twitter. +// Sign in with Facebook. -passport.use(new TwitterStrategy(secrets.twitter, function(req, accessToken, tokenSecret, profile, done) { +passport.use(new FacebookStrategy(secrets.facebook, function(req, accessToken, refreshToken, profile, done) { if (req.user) { - User.findOne({ twitter: profile.id }, function(err, existingUser) { + User.findOne({ facebook: profile.id }, function(err, existingUser) { if (existingUser) { - req.flash('errors', { msg: 'There is already a Twitter account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); + req.flash('errors', { msg: 'There is already a Facebook account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); done(err); } else { User.findById(req.user.id, function(err, user) { - user.twitter = profile.id; - user.tokens.push({ kind: 'twitter', accessToken: accessToken, tokenSecret: tokenSecret }); - user.profile.name = user.profile.name || profile.displayName; - user.profile.location = user.profile.location || profile._json.location; - user.profile.picture = user.profile.picture || profile._json.profile_image_url_https; - user.save(function(err) { - req.flash('info', { msg: 'Twitter account has been linked.' }); - done(err, user); - }); - }); - } - }); - - } else { - User.findOne({ twitter: profile.id }, function(err, existingUser) { - if (existingUser) return done(null, existingUser); - var user = new User(); - // Twitter will not provide an email address. Period. - // But a person’s twitter username is guaranteed to be unique - // so we can "fake" a twitter email address as follows: - user.email = profile.username + "@please_add_your_email_here.com"; - user.profile.username = profile.username; - user.twitter = profile.id; - user.tokens.push({ kind: 'twitter', accessToken: accessToken, tokenSecret: tokenSecret }); - user.profile.name = profile.displayName; - user.profile.location = profile._json.location; - user.profile.picture = profile._json.profile_image_url_https; - user.save(function(err) { - done(err, user); - }); - }); - } -})); - -// Sign in with Google. - -passport.use(new GoogleStrategy(secrets.google, function(req, accessToken, refreshToken, profile, done) { - if (req.user) { - User.findOne({ google: profile.id }, function(err, existingUser) { - if (existingUser) { - req.flash('errors', { msg: 'There is already a Google account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); - done(err); - } else { - User.findById(req.user.id, function(err, user) { - user.google = profile.id; - user.tokens.push({ kind: 'google', accessToken: accessToken }); + user.facebook = profile.id; + user.tokens.push({ kind: 'facebook', accessToken: accessToken }); user.profile.name = user.profile.name || profile.displayName; user.profile.gender = user.profile.gender || profile._json.gender; - user.profile.picture = user.profile.picture || profile._json.picture; + user.profile.picture = user.profile.picture || 'https://graph.facebook.com/' + profile.id + '/picture?type=large'; user.save(function(err) { - req.flash('info', { msg: 'Google account has been linked.' }); + req.flash('info', { msg: 'Facebook account has been linked.' }); done(err, user); }); }); } }); } else { - User.findOne({ google: profile.id }, function(err, existingUser) { + User.findOne({ facebook: profile.id }, function(err, existingUser) { if (existingUser) return done(null, existingUser); User.findOne({ email: profile._json.email }, function(err, existingEmailUser) { if (existingEmailUser) { - req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with Google manually from Account Settings.' }); + req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with Facebook manually from Account Settings.' }); done(err); } else { var user = new User(); user.email = profile._json.email; - user.google = profile.id; - user.tokens.push({ kind: 'google', accessToken: accessToken }); + user.facebook = profile.id; + user.tokens.push({ kind: 'facebook', accessToken: accessToken }); user.profile.name = profile.displayName; user.profile.gender = profile._json.gender; - user.profile.picture = profile._json.picture; + user.profile.picture = 'https://graph.facebook.com/' + profile.id + '/picture?type=large'; + user.profile.location = (profile._json.location) ? profile._json.location.name : ''; user.save(function(err) { done(err, user); }); - } - }); - }); - } -})); - -// Sign in with LinkedIn. - -passport.use(new LinkedInStrategy(secrets.linkedin, function(req, accessToken, refreshToken, profile, done) { - if (req.user) { - User.findOne({ linkedin: profile.id }, function(err, existingUser) { - if (existingUser) { - req.flash('errors', { msg: 'There is already a LinkedIn account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); - done(err); - } else { - User.findById(req.user.id, function(err, user) { - user.linkedin = profile.id; - user.tokens.push({ kind: 'linkedin', accessToken: accessToken }); - user.profile.name = user.profile.name || profile.displayName; - user.profile.location = user.profile.location || profile._json.location.name; - user.profile.picture = user.profile.picture || profile._json.pictureUrl; - user.profile.website = user.profile.website || profile._json.publicProfileUrl; - user.save(function(err) { - req.flash('info', { msg: 'LinkedIn account has been linked.' }); - done(err, user); + var transporter = nodemailer.createTransport({ + service: 'Mandrill', + auth: { + user: secrets.mandrill.user, + pass: secrets.mandrill.password + } }); - }); - } - }); - } else { - User.findOne({ linkedin: profile.id }, function(err, existingUser) { - if (existingUser) return done(null, existingUser); - User.findOne({ email: profile._json.emailAddress }, function(err, existingEmailUser) { - if (existingEmailUser) { - req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with LinkedIn manually from Account Settings.' }); - done(err); - } else { - var user = new User(); - user.linkedin = profile.id; - user.tokens.push({ kind: 'linkedin', accessToken: accessToken }); - user.email = profile._json.emailAddress; - user.profile.name = profile.displayName; - user.profile.location = profile._json.location.name; - user.profile.picture = profile._json.pictureUrl; - user.profile.website = profile._json.publicProfileUrl; - user.save(function(err) { - done(err, user); + var mailOptions = { + to: user.email, + from: 'Team@freecodecamp.com', + subject: 'Welcome to Free Code Camp!', + text: [ + 'Greetings from San Francisco!\n\n', + 'Thank you for joining our community.\n', + 'Feel free to email us at this address if you have any questions about Free Code Camp.\n', + "And if you have a moment, check out our blog: blog.freecodecamp.com.\n", + 'Good luck with the challenges!\n\n', + '- the Volunteer Camp Counselor Team' + ].join('') + }; + transporter.sendMail(mailOptions, function(err) { + if (err) { return err; } }); } }); @@ -225,273 +165,228 @@ passport.use(new GitHubStrategy(secrets.github, function(req, accessToken, refre user.save(function(err) { done(err, user); }); + var transporter = nodemailer.createTransport({ + service: 'Mandrill', + auth: { + user: secrets.mandrill.user, + pass: secrets.mandrill.password + } + }); + var mailOptions = { + to: user.email, + from: 'Team@freecodecamp.com', + subject: 'Welcome to Free Code Camp!', + text: [ + 'Greetings from San Francisco!\n\n', + 'Thank you for joining our community.\n', + 'Feel free to email us at this address if you have any questions about Free Code Camp.\n', + "And if you have a moment, check out our blog: blog.freecodecamp.com.\n", + 'Good luck with the challenges!\n\n', + '- the Volunteer Camp Counselor Team' + ].join('') + }; + transporter.sendMail(mailOptions, function(err) { + if (err) { return err; } + }); } }); }); } })); -// Sign in with Facebook. +// Sign in with Twitter. -passport.use(new FacebookStrategy(secrets.facebook, function(req, accessToken, refreshToken, profile, done) { +passport.use(new TwitterStrategy(secrets.twitter, function(req, accessToken, tokenSecret, profile, done) { if (req.user) { - User.findOne({ facebook: profile.id }, function(err, existingUser) { + User.findOne({ twitter: profile.id }, function(err, existingUser) { if (existingUser) { - req.flash('errors', { msg: 'There is already a Facebook account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); + req.flash('errors', { msg: 'There is already a Twitter account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); done(err); } else { User.findById(req.user.id, function(err, user) { - user.facebook = profile.id; - user.tokens.push({ kind: 'facebook', accessToken: accessToken }); + user.twitter = profile.id; + user.tokens.push({ kind: 'twitter', accessToken: accessToken, tokenSecret: tokenSecret }); + user.profile.username = user.profile.username || profile.username.toLowerCase(); + user.profile.name = user.profile.name || profile.displayName; + user.profile.location = user.profile.location || profile._json.location; + user.profile.picture = user.profile.picture || profile._json.profile_image_url_https.replace('_normal', ''); + user.profile.twitterHandle = user.profile.twitterHandle || profile.username.toLowerCase(); + user.save(function(err) { + req.flash('info', { msg: 'Twitter account has been linked.' }); + done(err, user); + }); + }); + } + }); + + } else { + User.findOne({ twitter: profile.id }, function(err, existingUser) { + if (existingUser) return done(null, existingUser); + var user = new User(); + user.profile.username = profile.username.toLowerCase(); + user.twitter = profile.id; + user.tokens.push({ kind: 'twitter', accessToken: accessToken, tokenSecret: tokenSecret }); + user.profile.name = profile.displayName; + user.profile.location = profile._json.location; + user.profile.picture = profile._json.profile_image_url_https.replace('_normal', ''); + user.profile.twitterHandle = user.profile.twitterHandle || profile.username.toLowerCase(); + user.save(function(err) { + done(err, user); + }); + }); + } +})); + +// Sign in with Google. + +passport.use(new GoogleStrategy(secrets.google, function(req, accessToken, refreshToken, profile, done) { + if (req.user) { + User.findOne({ google: profile.id }, function(err, existingUser) { + if (existingUser) { + req.flash('errors', { msg: 'There is already a Google account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); + done(err); + } else { + User.findById(req.user.id, function(err, user) { + user.google = profile.id; + user.tokens.push({ kind: 'google', accessToken: accessToken }); user.profile.name = user.profile.name || profile.displayName; user.profile.gender = user.profile.gender || profile._json.gender; - user.profile.picture = user.profile.picture || 'https://graph.facebook.com/' + profile.id + '/picture?type=large'; + user.profile.picture = user.profile.picture || profile._json.picture; user.save(function(err) { - req.flash('info', { msg: 'Facebook account has been linked.' }); + req.flash('info', { msg: 'Google account has been linked.' }); done(err, user); }); }); } }); } else { - User.findOne({ facebook: profile.id }, function(err, existingUser) { + User.findOne({ google: profile.id }, function(err, existingUser) { if (existingUser) return done(null, existingUser); User.findOne({ email: profile._json.email }, function(err, existingEmailUser) { if (existingEmailUser) { - req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with Facebook manually from Account Settings.' }); + req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with Google manually from Account Settings.' }); done(err); } else { var user = new User(); user.email = profile._json.email; - user.facebook = profile.id; - user.tokens.push({ kind: 'facebook', accessToken: accessToken }); + user.google = profile.id; + user.tokens.push({ kind: 'google', accessToken: accessToken }); user.profile.name = profile.displayName; user.profile.gender = profile._json.gender; - user.profile.picture = 'https://graph.facebook.com/' + profile.id + '/picture?type=large'; - user.profile.location = (profile._json.location) ? profile._json.location.name : ''; + user.profile.picture = profile._json.picture; user.save(function(err) { done(err, user); }); + var transporter = nodemailer.createTransport({ + service: 'Mandrill', + auth: { + user: secrets.mandrill.user, + pass: secrets.mandrill.password + } + }); + var mailOptions = { + to: user.email, + from: 'Team@freecodecamp.com', + subject: 'Welcome to Free Code Camp!', + text: [ + 'Greetings from San Francisco!\n\n', + 'Thank you for joining our community.\n', + 'Feel free to email us at this address if you have any questions about Free Code Camp.\n', + "And if you have a moment, check out our blog: blog.freecodecamp.com.\n", + 'Good luck with the challenges!\n\n', + '- the Volunteer Camp Counselor Team' + ].join('') + }; + transporter.sendMail(mailOptions, function(err) { + if (err) { return err; } + }); } }); }); } })); -// Sign in using Email and Password. -passport.use( - new LocalStrategy( - { usernameField: 'email' }, function(email, password, done) { - User.findOne({ email: email }, function(err, user) { - if (err) { return done(err); } +// Sign in with LinkedIn. - if (!user) { - return done(null, false, { message: 'Email ' + email + ' not found'}); - } - user.comparePassword(password, function(err, isMatch) { - if (err) { return done(err); } - - if (isMatch) { - return done(null, user); - } else { - return done(null, false, { message: 'Invalid email or password.' }); - } - }); - }); -})); - - -// Sign in with Facebook. -passport.use( - new FacebookStrategy( - secrets.facebook, function(req, accessToken, refreshToken, profile, done) { - if (req.user) { - User.findOne({ facebook: profile.id }, function(err, existingUser) { - if (err) { return done(err); } - - if (existingUser) { - req.flash('errors', { - msg: [ - 'There is already a Facebook account that belongs to you.', - 'Sign in with that account or delete it, then link it with', - 'your current account.' - ].join(' ') - }); - done(); - } else { - User.findById(req.user.id, function(err, user) { - if (err) { return done(err); } - - user.facebook = profile.id; - user.tokens.push({ - kind: 'facebook', - accessToken: accessToken - }); - - user.profile.name = user.profile.name || profile.displayName; - user.profile.gender = user.profile.gender || profile._json.gender; - - user.profile.picture = - user.profile.picture || - 'https://graph.facebook.com/' + - profile.id + - '/picture?type=large'; - - user.save(function(err) { - if (err) { return done(err); } - - req.flash( - 'info', { msg: 'Facebook account has been linked.' }); - done(null, user); - }); - }); - } - }); +passport.use(new LinkedInStrategy(secrets.linkedin, function(req, accessToken, refreshToken, profile, done) { + if (req.user) { + User.findOne({ linkedin: profile.id }, function(err, existingUser) { + if (existingUser) { + req.flash('errors', { msg: 'There is already a LinkedIn account that belongs to you. Sign in with that account or delete it, then link it with your current account.' }); + done(err); } else { - User.findOne({ facebook: profile.id }, function(err, existingUser) { - if (err) { return done(err); } - - if (existingUser) { return done(null, existingUser); } - - User.findOne( - { email: profile._json.email }, function(err, existingEmailUser) { - if (err) { return done(err); } - - var user = existingEmailUser || new User(); - user.email = user.email || profile._json.email; - user.facebook = profile.id; - user.tokens.push({ - kind: 'facebook', - accessToken: accessToken - }); - user.profile.name = user.profile.name || profile.displayName; - - user.profile.gender = - user.profile.gender || profile._json.gender; - - user.profile.picture = - user.profile.picture || - 'https://graph.facebook.com/' + - profile.id + - '/picture?type=large'; - - user.profile.location = - user.profile.location || - (profile._json.location) ? profile._json.location.name : ''; - - user.challengesComplete = user.challengesCompleted || []; - user.save(function(err) { - if (err) { return done(err); } - done(null, user); - }); - }); - }); - } -})); - -// Sign in with GitHub. - -passport.use( - new GitHubStrategy( - secrets.github, function(req, accessToken, refreshToken, profile, done) { - if (req.user) { - User.findOne({ github: profile.id }, function(err, existingUser) { - if (err) { return done(err); } - - if (existingUser) { - req.flash('errors', { - msg: [ - 'There is already a GitHub account that belongs to you.', - 'Sign in with that account or delete it, then link it with', - 'your current account.' - ].join(' ') - }); - done(); - } else { - User.findById(req.user.id, function(err, user) { - if (err) { return done(err); } - - user.github = profile.id; - user.tokens.push({ kind: 'github', accessToken: accessToken }); - user.profile.name = user.profile.name || profile.displayName; - - user.profile.picture = - user.profile.picture || profile._json.avatar_url; - - user.profile.location = - user.profile.location || profile._json.location; - - user.profile.website = - user.profile.website || profile._json.blog; - - user.save(function(err) { - if (err) { return done(err); } - - req.flash('info', { msg: 'GitHub account has been linked.' }); - done(null, user); - }); - }); - } - }); - } else { - User.findOne({ github: profile.id }, function(err, existingUser) { - if (err) { return done(err); } - - if (existingUser) { return done(null, existingUser); } - User.findOne( - { email: profile._json.email }, function(err, existingEmailUser) { - if (err) { return done(err); } - - var user = existingEmailUser || new User(); - user.email = user.email || profile._json.email; - user.github = profile.id; - user.tokens.push({ - kind: 'github', - accessToken: accessToken - }); - user.profile.name = user.profile.name || profile.displayName; - - user.profile.picture = - user.profile.picture || profile._json.avatar_url; - - user.profile.location = - user.profile.location || profile._json.location; - - user.profile.website = - user.profile.website || profile._json.blog; - - user.save(function(err) { - if (err) { return done(err); } - done(null, user); - }); + User.findById(req.user.id, function(err, user) { + user.linkedin = profile.id; + user.tokens.push({ kind: 'linkedin', accessToken: accessToken }); + user.profile.name = user.profile.name || profile.displayName; + user.profile.location = user.profile.location || profile._json.location.name; + user.profile.picture = user.profile.picture || profile._json.pictureUrl; + user.profile.website = user.profile.website || profile._json.publicProfileUrl; + user.save(function(err) { + req.flash('info', { msg: 'LinkedIn account has been linked.' }); + done(err, user); }); }); } + }); + } else { + User.findOne({ linkedin: profile.id }, function(err, existingUser) { + if (existingUser) return done(null, existingUser); + User.findOne({ email: profile._json.emailAddress }, function(err, existingEmailUser) { + if (existingEmailUser) { + req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with LinkedIn manually from Account Settings.' }); + done(err); + } else { + var user = new User(); + user.linkedin = profile.id; + user.tokens.push({ kind: 'linkedin', accessToken: accessToken }); + user.email = profile._json.emailAddress; + user.profile.name = profile.displayName; + user.profile.location = profile._json.location.name; + user.profile.picture = profile._json.pictureUrl; + user.profile.website = profile._json.publicProfileUrl; + user.save(function(err) { + done(err, user); + }); + var transporter = nodemailer.createTransport({ + service: 'Mandrill', + auth: { + user: secrets.mandrill.user, + pass: secrets.mandrill.password + } + }); + var mailOptions = { + to: user.email, + from: 'Team@freecodecamp.com', + subject: 'Welcome to Free Code Camp!', + text: [ + 'Greetings from San Francisco!\n\n', + 'Thank you for joining our community.\n', + 'Feel free to email us at this address if you have any questions about Free Code Camp.\n', + "And if you have a moment, check out our blog: blog.freecodecamp.com.\n", + 'Good luck with the challenges!\n\n', + '- the Volunteer Camp Counselor Team' + ].join('') + }; + transporter.sendMail(mailOptions, function(err) { + if (err) { return err; } + }); + } + }); + }); + } })); +// Login Required middleware. - - -function isAuthenticated(req, res, next) { +exports.isAuthenticated = function(req, res, next) { if (req.isAuthenticated()) return next(); res.redirect('/login'); -} - -function hasEmail(req, res) { - if (req.user) { - if (req.user.email) { - res.redirect('/'); - } else { - req.flash('info', { - msg: 'Please add your email address before starting our challenges.' - }); - res.redirect('/account'); - } - } -} +}; // Authorization Required middleware. -function isAuthorized(req, res, next) { + +exports.isAuthorized = function(req, res, next) { var provider = req.path.split('/').slice(-1)[0]; if (_.find(req.user.tokens, { kind: provider })) { @@ -499,4 +394,4 @@ function isAuthorized(req, res, next) { } else { res.redirect('/auth/' + provider); } -} +}; \ No newline at end of file diff --git a/controllers/user.js b/controllers/user.js index ba4468e55cc..bc46b8553fb 100644 --- a/controllers/user.js +++ b/controllers/user.js @@ -70,7 +70,7 @@ exports.logout = function(req, res) { exports.getEmailSignin = function(req, res) { if (req.user) return res.redirect('/'); - res.render('account/email-signup', { + res.render('account/email-signin', { title: 'Sign in to your Free Code Camp Account' }); }; @@ -82,7 +82,7 @@ exports.getEmailSignin = function(req, res) { exports.getEmailSignup = function(req, res) { if (req.user) return res.redirect('/'); - res.render('account/email-signin', { + res.render('account/email-signup', { title: 'Create Your Free Code Camp Account' }); }; @@ -93,6 +93,7 @@ exports.getEmailSignup = function(req, res) { */ exports.postEmailSignup = function(req, res, next) { + console.log('post email signup called'); req.assert('email', 'Email is not valid').isEmail(); req.assert('password', 'Password must be at least 4 characters long').len(4); req.assert('confirmPassword', 'Passwords do not match') @@ -103,11 +104,15 @@ exports.postEmailSignup = function(req, res, next) { if (errors) { req.flash('errors', errors); return res.redirect('/email-signup'); + console.log(errors); } var user = new User({ email: req.body.email, - password: req.body.password + password: req.body.password, + profile : { + username: req.body.username + } }); User.findOne({ email: req.body.email }, function(err, existingUser) { @@ -166,6 +171,33 @@ exports.getAccount = function(req, res) { }); }; +/** + * Unique username check API Call + */ + +exports.checkUniqueUsername = function(req, res) { + User.count({'profile.username': req.params.username.toLowerCase()}, function (err, data) { + if (data == 1) { + return res.send(true); + } else { + return res.send(false); + } + }); +}; +/** + * Unique email check API Call + */ + +exports.checkUniqueEmail = function(req, res) { + User.count({'email': decodeURIComponent(req.params.email).toLowerCase()}, function (err, data) { + if (data == 1) { + return res.send(true); + } else { + return res.send(false); + } + }); +}; + /** * GET /campers/:username @@ -173,7 +205,7 @@ exports.getAccount = function(req, res) { */ exports.returnUser = function(req, res, next) { - User.find({'profile.username': req.params.username}, function(err, user) { + User.find({'profile.username': req.params.username.toLowerCase()}, function(err, user) { if (err) { debug('Username err: ', err); next(err); } if (user[0]) { var user = user[0]; diff --git a/public/js/main.js b/public/js/main.js index 95072493b9a..3f81c3c571f 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -24,7 +24,6 @@ $(document).ready(function() { l = location.pathname.split('/'); cn = l[l.length - 1]; - console.log(cn); $.ajax({ type: 'POST', data: {challengeNumber: cn}, @@ -47,7 +46,65 @@ profileValidation.controller('profileValidationController', ['$scope', '$http', function($scope, $http) { $http.get('/account/api').success(function(data) { $scope.user = data.user; + $scope.user.profile.username = $scope.user.profile.username.toLowerCase(); + $scope.storedUsername = data.user.profile.username; + $scope.storedEmail = data.user.email; + $scope.user.email = $scope.user.email.toLowerCase(); + $scope.user.profile.twitterHandle = $scope.user.profile.twitterHandle.toLowerCase(); }); } ]); +profileValidation.controller('emailSignUpController', ['$scope', + function($scope) { + + } +]); + +profileValidation.controller('emailSignInController', ['$scope', + function($scope) { + + } +]); + +profileValidation.directive('uniqueUsername', function($http) { + return { + restrict: 'A', + require: 'ngModel', + link: function (scope, element, attrs, ngModel) { + element.bind("keyup", function (event) { + ngModel.$setValidity('unique', true); + if (element.val()) { + $http.get("/api/checkUniqueUsername/" + element.val()).success(function (data) { + if (element.val() == scope.storedUsername) { + ngModel.$setValidity('unique', true); + } else if (data) { + ngModel.$setValidity('unique', false); + } + }); + } + }); + } + } +}); + +profileValidation.directive('uniqueEmail', function($http) { + return { + restrict: 'A', + require: 'ngModel', + link: function (scope, element, attrs, ngModel) { + element.bind("keyup", function (event) { + ngModel.$setValidity('unique', true); + if (element.val()) { + $http.get("/api/checkUniqueEmail/" + encodeURIComponent(element.val())).success(function (data) { + if (element.val() == scope.storedEmail) { + ngModel.$setValidity('unique', true); + } else if (data) { + ngModel.$setValidity('unique', false); + } + }); + }; + }); + } + } +}); \ No newline at end of file diff --git a/views/account/email-signin.jade b/views/account/email-signin.jade index ebc38961cc1..860a9ce0029 100644 --- a/views/account/email-signin.jade +++ b/views/account/email-signin.jade @@ -1,29 +1,27 @@ extends ../layout block content - .jumbotron.text-center - h2 Sign up with an email address here: - form.form-horizontal(method='POST') - input(type='hidden', name='_csrf', value=_csrf) - .form-group - .col-sm-6.col-sm-offset-3 - input.form-control(type='email', name='email', id='email', placeholder='Email', autofocus) - .form-group - .col-sm-6.col-sm-offset-3 - input.form-control(type='password', name='password', id='password', placeholder='Password') - .form-group - .col-sm-6.col-sm-offset-3 - input.form-control(type='password', name='confirmPassword', id='confirmPassword', placeholder='Confirm Password') - .form-group - .col-sm-offset-3.col-sm-6 - button.btn.btn-success(type='submit') - span.ion-person-add - | Signup - br - br - br - br - br - br - br - br - br + .jumbotron.text-center(ng-controller="emailSignInController") + h2 Sign in with an email address here: + form(method='POST', action='/email-signin') + input(type='hidden', name='_csrf', value=_csrf) + .col-sm-6.col-sm-offset-3 + .form-group + input.form-control(type='email', name='email', id='email', placeholder='Email', ng-model='email', autofocus=true) + | {{ $scope.email }} + .form-group + input.form-control(type='password', name='password', id='password', placeholder='Password', ng-model='password') + .form-group + button.btn.btn-primary(type='submit') + span.ion-android-hand + | Login + span    + a.btn.btn-info(href='/forgot') Forgot your password? + br + br + br + br + br + br + br + br + br \ No newline at end of file diff --git a/views/account/email-signup.jade b/views/account/email-signup.jade index 0764a2ee18b..826af1f8067 100644 --- a/views/account/email-signup.jade +++ b/views/account/email-signup.jade @@ -1,26 +1,60 @@ extends ../layout block content - .jumbotron.text-center - h2 Sign in with an email address here: - form(method='POST') - input(type='hidden', name='_csrf', value=_csrf) - .col-sm-6.col-sm-offset-3 - .form-group - input.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true) - .form-group - input.form-control(type='password', name='password', id='password', placeholder='Password') - .form-group - button.btn.btn-primary(type='submit') - span.ion-android-hand - | Login - span    - a.btn.btn-info(href='/forgot') Forgot your password? - br - br - br - br - br - br - br - br - br \ No newline at end of file + .jumbotron.text-center + h2 Sign up with an email address here: + form.form-horizontal(method='POST', action='/email-signup', name="signupForm", novalidate="novalidate") + input(type='hidden', name='_csrf', value=_csrf) + .form-group + .col-sm-6.col-sm-offset-3 + input.form-control(type='email', ng-model='email', ng-keypress='', name='email', id='email', placeholder='email', autofocus, required, autocomplete="off", unique-email='') + .col-sm-6.col-sm-offset-3(ng-show="signupForm.email.$error.unique && !signupForm.email.$pristine") + alert(type='danger') + span.ion-close-circled + | This email is taken. + .form-group + .col-sm-6.col-sm-offset-3 + input.form-control(type='text', name='username', ng-keypress='', autocomplete="off", id='username', placeholder='username', ng-model='username', unique-username='', required, ng-minlength=5, ng-maxlength=20, ng-pattern="/^[A-z0-9_]+$/") + .col-sm-4.col-sm-offset-5(ng-show="profileForm.username.$error.pattern && !signupForm.username.$pristine") + alert(type='danger') + span.ion-close-circled + | Your username should only contain letters, numbers and underscores (az10_). + .col-sm-6.col-sm-offset-3(ng-show="signupForm.username.$error.unique && !signupForm.username.$pristine") + alert(type='danger') + span.ion-close-circled + | This username is taken. + .col-sm-6.col-sm-offset-3(ng-show="signupForm.username.$error.minlength && !signupForm.username.$pristine") + alert(type='danger') + span.ion-close-circled + | Your username must be at least 5 characters long. + .col-sm-6.col-sm-offset-3(ng-show="signupForm.username.$error.maxlength && !signupForm.username.$pristine") + alert(type='danger') + span.ion-close-circled + | Your usernames must be 20 characters or fewer. + .form-group + .col-sm-6.col-sm-offset-3 + input.form-control(type='password', ng-model='password', name='password', id='password', placeholder='password', required, ng-minlength=5) + .col-sm-6.col-sm-offset-3(ng-show="signupForm.password.$error.minlength && !signupForm.password.$pristine") + alert(type='danger') + span.ion-close-circled + | Your password must be at least 8 characters long. + .form-group + .col-sm-6.col-sm-offset-3 + input.form-control(type='password', ng-model='confirmPassword', name='confirmPassword', id='confirmPassword', placeholder='confirm password', required, ng-minlength=5) + .col-sm-6.col-sm-offset-3(ng-show="(confirmPassword !== password) && !signupForm.confirmPassword.$pristine") + alert(type='danger') + span.ion-close-circled + | Passwords must match. + .form-group + .col-sm-offset-3.col-sm-6 + button.btn.btn-success(type='submit') + span.ion-person-add + | Signup + br + br + br + br + br + br + br + br + br diff --git a/views/account/forgot.jade b/views/account/forgot.jade index a843a1e95bf..cc7f131cccc 100644 --- a/views/account/forgot.jade +++ b/views/account/forgot.jade @@ -1,15 +1,16 @@ extends ../layout block content - .col-sm-8.col-sm-offset-2 - form(method='POST') - legend Forgot Password - input(type='hidden', name='_csrf', value=_csrf) - .form-group - p Enter your email address below and we will send you password reset instructions. - label.control-label(for='email') Email - input.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true) - .form-group - button.btn.btn-primary(type='submit') - i.fa.fa-key - | Reset Password + .jumbotron + .col-sm-8.col-sm-offset-2 + form(method='POST') + h1 Forgot Password + input(type='hidden', name='_csrf', value=_csrf) + .form-group + p Enter your email address below and we will send you password reset instructions. + label.control-label(for='email') Email + input.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true) + .form-group + button.btn.btn-primary(type='submit') + i.fa.fa-key + | Reset Password diff --git a/views/account/login.jade b/views/account/login.jade index 7118d2e9315..b32eab50571 100644 --- a/views/account/login.jade +++ b/views/account/login.jade @@ -8,9 +8,9 @@ block content a.btn.btn-lg.btn-block.btn-facebook.btn-social(href='/auth/facebook') i.fa.fa-facebook | Sign in with Facebook - a.btn.btn-lg.btn-block.btn-github.btn-social(href='/auth/github') - i.fa.fa-github - | Sign in with GitHub + //a.btn.btn-lg.btn-block.btn-github.btn-social(href='/auth/github') + // i.fa.fa-github + // | Sign in with GitHub a.btn.btn-lg.btn-block.btn-linkedin.btn-social(href='/auth/linkedin') i.fa.fa-linkedin | Sign in with LinkedIn diff --git a/views/account/profile.jade b/views/account/profile.jade index b51b83b9f20..7fcef2ef01e 100644 --- a/views/account/profile.jade +++ b/views/account/profile.jade @@ -13,10 +13,10 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='name') Name * .col-sm-4 - input.form-control(type='text', placeholder='Name', name='name', ng-model='user.profile.name', ng-minlength='3', ng-maxlength='50', required='required', id='name') - .col-sm-4.col-sm-offset-5(ng-show="profileForm.name.$invalid && !profileForm.name.$pristine && profileForm.name.$error.required") + input.form-control(type='text', placeholder='Name', name='name', autocomplete="off", ng-model='user.profile.name', ng-minlength='3', ng-maxlength='50', required='required', id='name') + .col-sm-4.col-sm-offset-5(ng-show="profileForm.name.$invalid && profileForm.name.$error.required") alert(type='danger') - span.ion-close-circled + span.ion-close-circled(id='#name-error') | Your name is required. .col-sm-4.col-sm-offset-5(ng-show='profileForm.name.$error.minlength && !profileForm.name.$pristine') alert(type='danger') @@ -30,11 +30,15 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='username') Username (path to public profile) * .col-sm-4 - input.form-control(type='text', placeholder='username' name='username', id='username', ng-model='user.profile.username', required='required', ng-minlength='5', ng-maxlength='20') - .col-sm-4.col-sm-offset-5(ng-show="profileForm.username.$error.required && !profileForm.username.$pristine") + input.form-control(type='text', placeholder='username' name='username', autocomplete="off", id='username', ng-model='user.profile.username', required='required', ng-minlength='5', ng-maxlength='20', ng-keypress='', unique-username='', ng-pattern="/^[A-z0-9_]+$/") + .col-sm-4.col-sm-offset-5(ng-show="profileForm.username.$error.pattern") alert(type='danger') span.ion-close-circled - | Please enter a username. + | Your username should only contain letters, numbers and underscores (az10_). + .col-sm-4.col-sm-offset-5(ng-show="profileForm.username.$error.required") + alert(type='danger') + span.ion-close-circled + | Your username is required. .col-sm-4.col-sm-offset-5(ng-show="profileForm.username.$error.minlength && !profileForm.username.$pristine") alert(type='danger') span.ion-close-circled @@ -43,24 +47,32 @@ block content alert(type='danger') span.ion-close-circled | Your username must be fewer than 15 characters. + .col-sm-4.col-sm-offset-5(ng-show="profileForm.username.$error.unique && !profileForm.username.$pristine && $scope.storedUsername !== user.profile.username") + alert(type='danger') + span.ion-close-circled + | That username is already taken. .form-group label.col-sm-3.col-sm-offset-2.control-label(for='email') Email * .col-sm-4 - input.form-control(type='email', name='email', id='email', ng-model='user.email', required='required') - .col-sm-4.col-sm-offset-5(ng-show="profileForm.email.$error.required && !profileForm.email.$pristine") + input.form-control(type='email', name='email', id='email', autocomplete="off", ng-model='user.email', required='required', ng-keypress='', unique-email='') + .col-sm-4.col-sm-offset-5(ng-show="profileForm.email.$error.required") alert(type='danger') span.ion-close-circled - | An email address is required. + | Your email address is required. .col-sm-4.col-sm-offset-5(ng-show="profileForm.$error.email && !profileForm.email.$pristine") alert(type='danger') span.ion-close-circled | Please enter a valid email format. + .col-sm-4.col-sm-offset-5(ng-show="profileForm.email.$error.unique && !profileForm.email.$pristine") + alert(type='danger') + span.ion-close-circled + | That email is already taken. .form-group label.col-sm-3.col-sm-offset-2.control-label(for='location') Location .col-sm-4 - input.form-control(type='text', name='location', id='location', ng-model='user.profile.location') + input.form-control(type='text', name='location', autocomplete="off", id='location', ng-model='user.profile.location') .form-group label.col-sm-3.col-sm-offset-2.control-label(for='email') Link to Profile Photo (1:1 ratio) @@ -74,7 +86,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='bio') Bio (140 characters) .col-sm-4 - input.form-control(type='text', name='bio', ng-model='user.profile.bio', ng-maxlength='140', id='bio') + input.form-control(type='text', name='bio', autocomplete="off", ng-model='user.profile.bio', ng-maxlength='140', id='bio') .col-sm-4.col-sm-offset-5(ng-show='profileForm.bio.$error.maxlength && !profileForm.bio.$pristine') alert(type='danger') span.ion-close-circled @@ -94,11 +106,11 @@ block content .col-sm-4 .input-group.twitter-input span.input-group-addon @ - input.form-control(type='text', name='twitterHandle', id='twitterHandle', ng-model='user.profile.twitterHandle', ng-maxlength='15', ng-pattern="/^[A-z0-9_]+$/") + input.form-control(type='text', name='twitterHandle', autocomplete="off", id='twitterHandle', ng-model='user.profile.twitterHandle', ng-maxlength='15', ng-pattern="/^[A-z0-9_]+$/") .col-sm-4.col-sm-offset-5(ng-show="profileForm.twitterHandle.$error.pattern") alert(type='danger') span.ion-close-circled - | Your Twitter handle should only contain letters, numbers and underscores (@az10_). + | Your Twitter handle should only contain letters, numbers and underscores (az10_). .col-sm-4.col-sm-offset-5(ng-show='profileForm.twitterHandle.$error.maxlength && !profileForm.twitterHandle.$pristine') alert(type='danger') span.ion-close-circled @@ -106,7 +118,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='email') Github .col-sm-4 - input.form-control(type='url', name='githubProfile', id='githubProfile', ng-model='user.profile.githubProfile', placeholder='http://') + input.form-control(type='url', name='githubProfile', id='githubProfile', autocomplete="off", ng-model='user.profile.githubProfile', placeholder='http://') .col-sm-4.col-sm-offset-5(ng-show="profileForm.githubProfile.$error.url && !profileForm.githubProfile.$pristine") alert(type='danger') span.ion-close-circled @@ -115,7 +127,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='email') CodePen .col-sm-4 - input.form-control(type='url', name='codepenProfile', id='codepenProfile', ng-model='user.profile.codepenProfile', placeholder='http://') + input.form-control(type='url', name='codepenProfile', id='codepenProfile', autocomplete="off", ng-model='user.profile.codepenProfile', placeholder='http://') .col-sm-4.col-sm-offset-5(ng-show="profileForm.codepenProfile.$error.url && !profileForm.codepenProfile.$pristine") alert(type='danger') span.ion-close-circled @@ -124,7 +136,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='email') CoderByte .col-sm-4 - input.form-control(type='url', name='coderbyteProfile', id='coderbyteProfile', ng-model='user.profile.coderbyteProfile', placeholder='http://') + input.form-control(type='url', name='coderbyteProfile', id='coderbyteProfile', autocomplete="off", ng-model='user.profile.coderbyteProfile', placeholder='http://') .col-sm-4.col-sm-offset-5(ng-show="profileForm.coderbyteProfile.$error.url && !profileForm.coderbyteProfile.$pristine") alert(type='danger') span.ion-close-circled @@ -133,7 +145,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='email') LinkedIn .col-sm-4 - input.form-control(type='url', name='linkedinProfile', id='linkedinProfile', ng-model='user.profile.linkedinProfile', placeholder='http://') + input.form-control(type='url', name='linkedinProfile', id='linkedinProfile', autocomplete="off", ng-model='user.profile.linkedinProfile', placeholder='http://') .col-sm-4.col-sm-offset-5(ng-show="profileForm.linkedinProfile.$error.url && !profileForm.linkedinProfile.$pristine") alert(type='danger') span.ion-close-circled @@ -154,7 +166,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website1Title') Title .col-sm-4 - input.form-control(type='text', name='website1Title', id='website1Title', ng-model='user.portfolio.website1Title', ng-maxlength='140') + input.form-control(type='text', name='website1Title', id='website1Title', autocomplete="off", ng-model='user.portfolio.website1Title', ng-maxlength='140') .col-sm-4.col-sm-offset-5(ng-show="profileForm.website1Title.$error.maxlength && !profileForm.website1Title.$pristine") alert(type='danger') span.ion-close-circled @@ -163,12 +175,12 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website1Link') Link .col-sm-4 - input.form-control(type='text', name='website1Link', id='website1Link', ng-model='user.portfolio.website1Link', placeholder='http://') + input.form-control(type='text', name='website1Link', id='website1Link', autocomplete="off", ng-model='user.portfolio.website1Link', placeholder='http://') .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website1Image') Image Link (4:3 ratio) .col-sm-4 - input.form-control(type='text', name='website1Image', id='website1Image', ng-model='user.portfolio.website1Image', placeholder='http://') + input.form-control(type='text', name='website1Image', id='website1Image', autocomplete="off", ng-model='user.portfolio.website1Image', placeholder='http://') .col-sm-4.col-sm-offset-5.flat-top h3 Second Portfolio Project @@ -176,7 +188,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website2Title') Title .col-sm-4 - input.form-control(type='text', name='website2Title', id='website2Title', ng-model='user.portfolio.website2Title', ng-maxlength='140') + input.form-control(type='text', name='website2Title', id='website2Title', autocomplete="off", ng-model='user.portfolio.website2Title', ng-maxlength='140') .col-sm-4.col-sm-offset-5(ng-show="profileForm.website2Title.$error.maxlength && !profileForm.website2Title.$pristine") alert(type='danger') span.ion-close-circled @@ -185,12 +197,12 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website2Link') Link .col-sm-4 - input.form-control(type='text', name='website2Link', id='website2Link', ng-model='user.portfolio.website2Link', placeholder='http://') + input.form-control(type='text', name='website2Link', id='website2Link', autocomplete="off", ng-model='user.portfolio.website2Link', placeholder='http://') .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website2Image') Image Link (4:3 ratio) .col-sm-4 - input.form-control(type='text', name='website2Image', id='website2Image', ng-model='user.portfolio.website2Image', placeholder='http://') + input.form-control(type='text', name='website2Image', id='website2Image', autocomplete="off", ng-model='user.portfolio.website2Image', placeholder='http://') .col-sm-4.col-sm-offset-5.flat-top h3 Third Portfolio Project @@ -198,7 +210,7 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website3Title') Title .col-sm-4 - input.form-control(type='text', name='website3Title', id='website3Title', ng-model='user.portfolio.website3Title', ng-maxlength='140') + input.form-control(type='text', name='website3Title', id='website3Title', autocomplete="off", ng-model='user.portfolio.website3Title', ng-maxlength='140') .col-sm-4.col-sm-offset-5(ng-show="profileForm.website3Title.$error.maxlength && !profileForm.website3Title.$pristine") alert(type='danger') span.ion-close-circled @@ -207,12 +219,12 @@ block content .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website3Link') Link .col-sm-4 - input.form-control(type='text', name='website3Link', id='website3Link', ng-model='user.portfolio.website3Link', placeholder='http://') + input.form-control(type='text', name='website3Link', id='website3Link', autocomplete="off", ng-model='user.portfolio.website3Link', placeholder='http://') .form-group label.col-sm-3.col-sm-offset-2.control-label(for='website3Image') Image Link (4:3 ratio) .col-sm-4 - input.form-control(type='text', name='website3Image', id='website3Image', ng-model='user.portfolio.website3Image', placeholder='http://') + input.form-control(type='text', name='website3Image', id='website3Image', autocomplete="off", ng-model='user.portfolio.website3Image', placeholder='http://') .form-group .col-sm-offset-5.col-sm-4 @@ -229,7 +241,7 @@ block content a.btn.btn-lg.btn-block.btn-primary.btn-link-social(href='/') Take me to my current challenge a.btn.btn-lg.btn-block.btn-warning.btn-link-social(href='/logout') Sign out br - - if (!user.google || !user.facebook || !user.github || !user.linkedin || !user.twitter) + - if (!user.google || !user.facebook || /*!user.github ||*/ !user.linkedin || !user.twitter) .panel.panel-primary .panel-heading.text-center Link other services to your account: .panel-body @@ -308,7 +320,3 @@ block content button.btn.btn-danger.btn-block(type='submit') span.ion-trash-b | Yes, Delete my account - - - - diff --git a/views/layout.jade b/views/layout.jade index 3aaabbc3dd7..8ff03c4c46f 100644 --- a/views/layout.jade +++ b/views/layout.jade @@ -1,14 +1,14 @@ doctype html html(ng-app='profileValidation') head - script(src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js") - script(src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.8/angular.min.js") - script(src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-bootstrap/0.12.0/ui-bootstrap-tpls.min.js") - script(src='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/js/bootstrap.min.js') - link(rel='shortcut icon', href='https://s3.amazonaws.com/freecodecamp/favicon.ico') - link(rel='stylesheet', href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css') - link(rel='stylesheet', href='https://maxcdn.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css') - link(rel='stylesheet', href='https://code.ionicframework.com/ionicons/2.0.0/css/ionicons.min.css') + script(src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js") + script(src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.8/angular.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular-ui-bootstrap/0.12.0/ui-bootstrap-tpls.min.js") + script(src='//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/js/bootstrap.min.js') + link(rel='shortcut icon', href='//s3.amazonaws.com/freecodecamp/favicon.ico') + link(rel='stylesheet', href='//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css') + link(rel='stylesheet', href='//maxcdn.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css') + link(rel='stylesheet', href='//code.ionicframework.com/ionicons/2.0.0/css/ionicons.min.css') include partials/meta title #{title} | Free Code Camp meta(charset='utf-8')