diff --git a/api-server/server/boot/news.js b/api-server/server/boot/news.js index 8ee501075a..b7dc2e88b9 100644 --- a/api-server/server/boot/news.js +++ b/api-server/server/boot/news.js @@ -1,214 +1,259 @@ -// import React from 'react'; -// import { renderToString } from 'react-dom/server'; -// // import { StaticRouter } from 'react-router-dom'; -// import { has } from 'lodash'; +import { has, pick, isEmpty } from 'lodash'; import debug from 'debug'; +import { reportError } from '../middlewares/error-reporter'; -// import NewsApp from '../../news/NewsApp'; - -const routerLog = debug('fcc:boot:news:router'); -const apiLog = debug('fcc:boot:news:api'); +const log = debug('fcc:boot:news'); export default function newsBoot(app) { - // const router = app.loopback.Router(); - // const api = app.loopback.Router(); + const api = app.loopback.Router(); - // router.get('/n', (req, res) => res.redirect('/news')); - // router.get('/n/:shortId', createShortLinkHandler(app)); + api.get('/n/:shortId', createShortLinkHandler(app)); - // router.get('/news', serveNewsApp); - // router.get('/news/*', serveNewsApp); + api.post('/p', createPopularityHandler(app)); - // api.post('/p', createPopularityHandler(app)); - - // app.use(api); - // app.use(router); + app.use('/internal', api); } -// function serveNewsApp(req, res) { -// const context = {}; -// const markup = renderToString( -// -// -// -// ); -// if (context.url) { -// routerLog('redirect found in `renderToString`'); -// // 'client-side' routing hit on a redirect -// return res.redirect(context.url); -// } -// routerLog('news markup sending'); -// return res.render('layout-news', { title: 'News | freeCodeCamp', markup }); -// } +function createShortLinkHandler(app) { + const { Article } = app.models; -// function createShortLinkHandler(app) { -// const { Article } = app.models; + const referralHandler = createReferralHandler(app); -// const referralHandler = createRerralHandler(app); + return function shortLinkHandler(req, res, next) { + const { query, user } = req; + const { shortId } = req.params; -// return function shortLinkHandler(req, res, next) { -// const { query, user } = req; -// const { shortId } = req.params; + // We manually report the error here as it should not affect this request + referralHandler(query, shortId, !!user).catch(err => reportError(err)); -// referralHandler(query, shortId, !!user); + if (!shortId) { + return res.sendStatus(400); + } + return Article.findOne( + { + where: { + or: [{ shortId }, { slugPart: shortId }] + } + }, + (err, article) => { + if (err) { + next(err); + } + if (!article) { + return res.status(404).send('Could not find article by shortId'); + } + const { + slugPart, + shortId, + author: { username } + } = article; + const slug = `/news/${username}/${slugPart}--${shortId}`; + const articleData = { + ...pick(article, [ + 'author', + 'renderableContent', + 'firstPublishedDate', + 'viewCount', + 'title', + 'featureImage', + 'slugPart', + 'shortId', + 'meta' + ]), + slug + }; + return res.json(articleData); + } + ); + }; +} -// routerLog(req.origin); -// routerLog(query.refsource); -// if (!shortId) { -// return res.redirect('/news'); -// } -// routerLog('shortId', shortId); -// return Article.findOne( -// { -// where: { -// or: [{ shortId }, { slugPart: shortId }] -// } -// }, -// (err, article) => { -// if (err) { -// next(err); -// } -// if (!article) { -// return res.redirect('/news'); -// } -// const { -// slugPart, -// shortId, -// author: { username } -// } = article; -// const slug = `/news/${username}/${slugPart}--${shortId}`; -// return res.redirect(slug); -// } -// ); -// }; -// } +function createPopularityHandler(app) { + const { Article, Popularity } = app.models; -// function createPopularityHandler(app) { -// const { Article, Popularity } = app.models; + function findArticleByShortId(shortId) { + return new Promise((resolve, reject) => + Article.findOne({ where: { shortId } }, (err, article) => { + if (err) { + log('Error returned from Article.findOne(shortId)'); + return reject(err); + } + log('article found'); + return resolve(article); + }) + ); + } -// return function handlePopularityStats(req, res, next) { -// const { body, user } = req; + function findPopularityByShortId(shortId) { + return new Promise((resolve, reject) => + Popularity.findOne( + { where: { articleId: shortId } }, + (err, popularity) => { + if (err) { + log('Error returned from Popularity.findOne(shortId)'); + return reject(err); + } + log('popularity found'); + return resolve(popularity); + } + ) + ); + } -// if ( -// !has(body, 'event') || -// !has(body, 'timestamp') || -// !has(body, 'shortId') -// ) { -// console.warn('Popularity event recieved from client is malformed'); -// console.log(JSON.stringify(body, null, 2)); -// // sending 200 because the client shouldn't care for this -// return res.sendStatus(200); -// } -// res.sendStatus(200); -// const { shortId } = body; -// apiLog('shortId', shortId); -// const populartiyUpdate = { -// ...body, -// byAuthenticatedUser: !!user -// }; -// Popularity.findOne({ where: { articleId: shortId } }, (err, popularity) => { -// if (err) { -// apiLog(err); -// return next(err); -// } -// if (popularity) { -// return popularity.updateAttribute( -// 'events', -// [populartiyUpdate, ...popularity.events], -// err => { -// if (err) { -// apiLog(err); -// return next(err); -// } -// return apiLog('poplarity updated'); -// } -// ); -// } -// return Popularity.create( -// { -// events: [populartiyUpdate], -// articleId: shortId -// }, -// err => { -// if (err) { -// apiLog(err); -// return next(err); -// } -// return apiLog('poulartiy created'); -// } -// ); -// }); -// return body.event === 'view' -// ? Article.findOne({ where: { shortId } }, (err, article) => { -// if (err) { -// apiLog(err); -// next(err); -// } -// return article.updateAttributes( -// { viewCount: article.viewCount + 1 }, -// err => { -// if (err) { -// apiLog(err); -// return next(err); -// } -// return apiLog('article views updated'); -// } -// ); -// }) -// : null; -// }; -// } + function createPopularity(popularityUpdate, shortId) { + return new Promise((resolve, reject) => + Popularity.create( + { + events: [popularityUpdate], + articleId: shortId + }, + err => { + if (err) { + return reject(err); + } + log('poulartiy created'); + return resolve(); + } + ) + ); + } -// function createRerralHandler(app) { -// const { Popularity } = app.models; + function updatePopularity(popularity, popularityUpdate) { + return new Promise((resolve, reject) => + popularity.updateAttribute( + 'events', + [popularityUpdate, ...popularity.events], + err => { + if (err) { + log('Error returned from popularity.updateAttribute()'); + return reject(err); + } + log('poplarity updated'); + return resolve(); + } + ) + ); + } -// return function referralHandler(query, shortId, byAuthenticatedUser) { -// if (!query.refsource) { -// return null; -// } -// const eventUpdate = { -// event: `referral - ${query.refsource}`, -// timestamp: new Date(Date.now()), -// byAuthenticatedUser -// }; -// return Popularity.findOne( -// { where: { articleId: shortId } }, -// (err, popularity) => { -// if (err) { -// console.error( -// 'Failed finding a `Popularity` in a referral handler', -// err -// ); -// return null; -// } + function incrementArticleViews(article) { + return new Promise((resolve, reject) => + article.updateAttributes({ $inc: { viewCount: 1 } }, err => { + if (err) { + log(err); + return reject(err); + } + log('article views updated'); + return resolve(); + }) + ); + } -// if (popularity) { -// return popularity.updateAttribute( -// 'events', -// [eventUpdate, ...popularity.events], -// err => { -// if (err) { -// console.error( -// 'Failed in updating the `events` attribute of a `popularity`', -// err -// ); -// } -// } -// ); -// } -// return Popularity.create( -// { -// events: [eventUpdate], -// articleId: shortId -// }, -// err => { -// if (err) { -// return console.error('Failed creating a new `Popularity`', err); -// } -// return apiLog('poulartiy created'); -// } -// ); -// } -// ); -// }; -// } + return async function handlePopularityStats(req, res, next) { + const { body, user } = req; + + if ( + !has(body, 'event') || + !has(body, 'timestamp') || + !has(body, 'shortId') + ) { + console.warn('Popularity event recieved from client is malformed'); + console.log(JSON.stringify(body, null, 2)); + // sending 200 because the client shouldn't care for this + return res.sendStatus(200); + } + const { shortId } = body; + log('shortId', shortId); + + const articlePromise = findArticleByShortId(shortId); + const popularityPromise = findPopularityByShortId(shortId); + + const [article, popularity] = await Promise.all([ + articlePromise, + popularityPromise + ]).catch(err => { + log('find catch'); + return next(err); + }); + if (!article || isEmpty(article)) { + log('No article found to handle the populartity update'); + // sending 200 because the client shouldn't care for this + return res.sendStatus(200); + } + + const populartiyUpdate = { + ...body, + byAuthenticatedUser: !!user + }; + + const populartiyUpdateOrCreatePromise = isEmpty(popularity) + ? createPopularity(populartiyUpdate, shortId) + : updatePopularity(popularity, populartiyUpdate); + const maybeUpdateArticlePromise = + body.event === 'view' ? incrementArticleViews(article) : null; + return Promise.all([ + populartiyUpdateOrCreatePromise, + maybeUpdateArticlePromise + ]) + .then(() => res.sendStatus(200)) + .catch(err => { + log('updates catch'); + return next(err); + }); + }; +} + +function createReferralHandler(app) { + const { Popularity } = app.models; + + return function referralHandler(query, shortId, byAuthenticatedUser) { + if (!query.refsource) { + return Promise.resolve(); + } + const eventUpdate = { + event: `referral - ${query.refsource}`, + timestamp: new Date(Date.now()), + byAuthenticatedUser + }; + return new Promise((resolve, reject) => + Popularity.findOne( + { where: { articleId: shortId } }, + (err, popularity) => { + if (err) { + console.error( + 'Failed finding a `Popularity` in a referral handler', + err + ); + return reject(err); + } + + if (popularity) { + return popularity.updateAttribute( + 'events', + [eventUpdate, ...popularity.events], + err => { + if (err) { + return reject(err); + } + log('populartiy updated'); + return resolve(); + } + ); + } + return Popularity.create( + { + events: [eventUpdate], + articleId: shortId + }, + err => { + if (err) { + return reject(err); + } + log('poulartiy created'); + return resolve(); + } + ); + } + ) + ); + }; +} diff --git a/api-server/server/datasources.production.js b/api-server/server/datasources.production.js index b70b97e731..57cf572238 100644 --- a/api-server/server/datasources.production.js +++ b/api-server/server/datasources.production.js @@ -5,7 +5,8 @@ module.exports = { connector: 'mongodb', connectionTimeout: 10000, url: secrets.db, - useNewUrlParser: true + useNewUrlParser: true, + allowExtendedOperators: true }, mail: { connector: 'mail', diff --git a/api-server/server/middlewares/error-handlers.js b/api-server/server/middlewares/error-handlers.js index 14df134e18..5f3744869c 100644 --- a/api-server/server/middlewares/error-handlers.js +++ b/api-server/server/middlewares/error-handlers.js @@ -8,47 +8,10 @@ import { unwrapHandledError } from '../utils/create-handled-error.js'; const isDev = process.env.NODE_ENV !== 'production'; -// const toString = Object.prototype.toString; -// is full error or just trace -// _.toString(new Error('foo')) => "Error: foo -// Object.prototype.toString.call(new Error('foo')) => "[object Error]" -// const isInspect = val => !val.stack && _.toString(val) === toString.call(val); -// const stringifyErr = val => { -// if (val.stack) { -// return String(val.stack); -// } - -// const str = String(val); - -// return isInspect(val) ? -// inspect(val) : -// str; -// }; - -// const createStackHtml = _.flow( -// _.cond([ -// [isInspect, err => [err]], -// // may be stack or just err.msg -// [_.stubTrue, _.flow(stringifyErr, _.split('\n'), _.tail) ] -// ]), -// _.map(_.escape), -// _.map(line => `
  • ${line}`), -// _.join('') -// ); - -// const createErrorTitle = _.cond([ -// [ -// _.negate(isInspect), -// _.flow(stringifyErr, _.split('\n'), _.head, _.defaultTo('Error')) -// ], -// [_.stubTrue, _.constant('Error')] -// ]); - export default function prodErrorHandler() { // error handling in production. - // disabling eslint due to express parity rules for error handlers + // eslint-disable-next-line no-unused-vars return function(err, req, res, next) { - // eslint-disable-line const handled = unwrapHandledError(err); // respect handled error status let status = handled.status || err.status || res.statusCode; diff --git a/api-server/server/middlewares/error-reporter.js b/api-server/server/middlewares/error-reporter.js index 196b2dbe5d..514d3227c5 100644 --- a/api-server/server/middlewares/error-reporter.js +++ b/api-server/server/middlewares/error-reporter.js @@ -5,20 +5,30 @@ import { unwrapHandledError } from '../utils/create-handled-error.js'; -const { ROLLBAR_APP_ID } = process.env; +import { rollbar } from '../../../config/secrets'; -const rollbar = new Rollbar(ROLLBAR_APP_ID); +const { appId } = rollbar; +const reporter = new Rollbar(appId); const log = debug('fcc:middlewares:error-reporter'); -const errTemplate = ({message, ...restError}, req) => ` +const errTemplate = (error, req) => { + const { message, stack } = error; + return ` Time: ${new Date(Date.now()).toISOString()} Error: ${message} Is authenticated user: ${!!req.user} Route: ${JSON.stringify(req.route, null, 2)} +Stack: ${stack} -${JSON.stringify(restError, null, 2)} +// raw +${JSON.stringify(error, null, 2)} `; +}; + +export function reportError(err) { + return reporter.error(err.message, err); +} export default function errrorReporter() { if (process.env.NODE_ENV !== 'production' && process.env.ERROR_REPORTER) { @@ -44,6 +54,7 @@ export default function errrorReporter() { // logging the error provides us with more information, // i.e isAuthenticatedUser, req.route console.error(errTemplate(err, req)); - return rollbar.error(err.message, err); + reportError(err); + return next(err); }; } diff --git a/api-server/server/middlewares/jwt-authorization.js b/api-server/server/middlewares/jwt-authorization.js index 78250a559e..4d7043fd87 100644 --- a/api-server/server/middlewares/jwt-authorization.js +++ b/api-server/server/middlewares/jwt-authorization.js @@ -6,9 +6,17 @@ import { homeLocation } from '../../../config/env'; import { wrapHandledError } from '../utils/create-handled-error'; +// We need to tunnel through a proxy path set up within +// the gatsby app, at this time, that path is /internal +const whiteListRE = new RegExp([ + '^/internal/n/', + '^/internal/p\??' +].join('|')); + + export default () => function authorizeByJWT(req, res, next) { const path = req.path.split('/')[1]; - if (/^external$|^internal$/.test(path)) { + if (/^external$|^internal$/.test(path) && !whiteListRE.test(req.path)) { const cookie = req.signedCookies && req.signedCookies['jwt_access_token'] || req.cookie && req.cookie['jwt_access_token']; if (!cookie) { diff --git a/client/gatsby-config.js b/client/gatsby-config.js index d0236b4018..a0c262e5c1 100644 --- a/client/gatsby-config.js +++ b/client/gatsby-config.js @@ -32,7 +32,8 @@ module.exports = { '/certification/*', '/unsubscribed/*', '/user/*', - '/settings/*' + '/settings/*', + '/n/*' ] } }, @@ -45,6 +46,12 @@ module.exports = { curriculumPath: localeChallengesRootDir } }, + { + resolve: 'fcc-source-news', + options: { + maximumStaticRenderCount: 100 + } + }, { resolve: 'gatsby-source-filesystem', options: { diff --git a/client/gatsby-node.js b/client/gatsby-node.js index 8dbc1d649e..e3a4f80e66 100644 --- a/client/gatsby-node.js +++ b/client/gatsby-node.js @@ -8,8 +8,10 @@ const { createChallengePages, createBlockIntroPages, createSuperBlockIntroPages, - createGuideArticlePages + createGuideArticlePages, + createNewsArticle } = require('./utils/gatsby'); +const { createArticleSlug } = require('./utils/news'); const createByIdentityMap = { guideMarkdown: createGuideArticlePages, @@ -35,6 +37,15 @@ exports.onCreateNode = function onCreateNode({ node, actions, getNode }) { createNodeField({ node, name: 'slug', value: slug }); } } + if (node.internal.type === 'NewsArticleNode') { + const { + author: { username }, + slugPart, + shortId + } = node; + const slug = createArticleSlug({ username, shortId, slugPart }); + createNodeField({ node, name: 'slug', value: slug }); + } }; exports.createPages = function createPages({ graphql, actions }) { @@ -86,6 +97,19 @@ exports.createPages = function createPages({ graphql, actions }) { } } } + allNewsArticleNode( + sort: { fields: firstPublishedDate, order: DESC } + ) { + edges { + node { + id + shortId + fields { + slug + } + } + } + } } `).then(result => { if (result.errors) { @@ -126,6 +150,11 @@ exports.createPages = function createPages({ graphql, actions }) { return null; }); + // Create news article pages + result.data.allNewsArticleNode.edges.forEach( + createNewsArticle(createPage) + ); + return null; }) ); @@ -161,7 +190,9 @@ exports.onCreateWebpackConfig = ({ stage, rules, plugins, actions }) => { HOME_PATH: JSON.stringify( process.env.HOME_PATH || 'http://localhost:3000' ), - STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY || '') + STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY || ''), + ROLLBAR_CLIENT_ID: JSON.stringify(process.env.ROLLBAR_CLIENT_ID || ''), + ENVIRONMENT: JSON.stringify(process.env.NODE_ENV || 'development') }), new RmServiceWorkerPlugin() ] diff --git a/client/gatsby-ssr.js b/client/gatsby-ssr.js index 87be57ed92..fea4c0b15d 100644 --- a/client/gatsby-ssr.js +++ b/client/gatsby-ssr.js @@ -1,3 +1,4 @@ +/* global ROLLBAR_CLIENT_ID ENVIRONMENT */ import React from 'react'; import PropTypes from 'prop-types'; import { Provider } from 'react-redux'; @@ -5,14 +6,14 @@ import { Provider } from 'react-redux'; import headComponents from './src/head'; import { createStore } from './src/redux/createStore'; -import GuideNavigationContextProvider from './src/contexts/GuideNavigationContext'; +import GuideNavContextProvider from './src/contexts/GuideNavigationContext'; const store = createStore(); export const wrapRootElement = ({ element }) => { return ( - {element} + {element} ); }; @@ -23,28 +24,57 @@ wrapRootElement.propTypes = { export const onRenderBody = ({ setHeadComponents, setPostBodyComponents }) => { setHeadComponents([...headComponents]); - setPostBodyComponents([ -