From d2b8c59fd95da0c696a271d613ab9324378c57cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Sopy=C5=82o?= Date: Sun, 30 Dec 2018 16:11:34 +0100 Subject: [PATCH] chore: update the codebase with newer JS features --- controllers/auth.js | 47 +- controllers/connect.js | 23 +- controllers/encrypt.js | 3 +- controllers/handlePR.js | 29 +- controllers/process.js | 106 +- coverage/cobertura-coverage.xml | 1449 ++++++++++++---------------- lib/ErrorHandler.js | 122 +-- lib/Logger.js | 40 +- lib/Notification.js | 68 +- lib/OAuth.js | 67 +- lib/Staticman.js | 824 ++++++++-------- lib/SubscriptionsManager.js | 97 +- server.js | 306 +++--- test/helpers/CatchAllApiMock.js | 30 +- test/helpers/Config.js | 28 +- test/unit/controllers/auth.test.js | 4 +- test/unit/lib/Staticman.test.js | 2 + test/utils/coverage.svg | 2 +- 18 files changed, 1503 insertions(+), 1744 deletions(-) diff --git a/controllers/auth.js b/controllers/auth.js index ab02c80b..20110b4a 100644 --- a/controllers/auth.js +++ b/controllers/auth.js @@ -5,7 +5,7 @@ const oauth = require('../lib/OAuth') const RSA = require('../lib/RSA') const Staticman = require('../lib/Staticman') -module.exports = (req, res) => { +module.exports = async (req, res) => { const staticman = new Staticman(req.params) staticman.setConfigPath() @@ -31,34 +31,31 @@ module.exports = (req, res) => { ) } - return staticman.getSiteConfig() - .then(requestAccessToken) - .then((accessToken) => { - const git = gitFactory.create(req.params.service, { - oauthToken: accessToken - }) + try { + const siteConfig = await staticman.getSiteConfig() + const accessToken = await requestAccessToken(siteConfig) + const git = gitFactory.create(req.params.service, { + oauthToken: accessToken + }) - // TODO: Simplify this when v2 support is dropped. - const getUser = req.params.version === '2' && req.params.service === 'github' - ? git.api.users.get({}).then(({data}) => data) - : git.getCurrentUser() + // TODO: Simplify this when v2 support is dropped. + const getUser = req.params.version === '2' && req.params.service === 'github' + ? git.api.users.get({}).then(({data}) => data) + : git.getCurrentUser() - return getUser - .then((user) => { - res.send({ - accessToken: RSA.encrypt(accessToken), - user - }) - }) + const user = await getUser + res.send({ + accessToken: RSA.encrypt(accessToken), + user }) - .catch((err) => { - console.log('ERR:', err) + } catch (err) { + console.log('ERR:', err) - const statusCode = err.statusCode || 401 + const statusCode = err.statusCode || 401 - res.status(statusCode).send({ - statusCode, - message: err.message - }) + res.status(statusCode).send({ + statusCode, + message: err.message }) + } } diff --git a/controllers/connect.js b/controllers/connect.js index 537d0d24..bbe44e3f 100644 --- a/controllers/connect.js +++ b/controllers/connect.js @@ -4,7 +4,7 @@ const path = require('path') const config = require(path.join(__dirname, '/../config')) const GitHub = require(path.join(__dirname, '/../lib/GitHub')) -module.exports = (req, res) => { +module.exports = async (req, res) => { const ua = config.get('analytics.uaTrackingId') ? require('universal-analytics')(config.get('analytics.uaTrackingId')) : null @@ -16,7 +16,9 @@ module.exports = (req, res) => { token: config.get('githubToken') }) - return github.api.users.getRepoInvites({}).then(({data}) => { + try { + const { data } = await github.api.users.getRepoInvites({}) + let invitationId = null const invitation = data.some(invitation => { @@ -28,23 +30,22 @@ module.exports = (req, res) => { }) if (invitation) { - return github.api.users.acceptRepoInvite({ + await github.api.users.acceptRepoInvite({ invitation_id: invitationId }) + res.send('OK!') + + if (ua) { + ua.event('Repositories', 'Connect').send() + } } else { res.status(404).send('Invitation not found') } - }).then(response => { - res.send('OK!') - - if (ua) { - ua.event('Repositories', 'Connect').send() - } - }).catch(err => { // eslint-disable-line handle-callback-err + } catch (err) { res.status(500).send('Error') if (ua) { ua.event('Repositories', 'Connect error').send() } - }) + } } diff --git a/controllers/encrypt.js b/controllers/encrypt.js index ab8d26bf..4480d3c1 100644 --- a/controllers/encrypt.js +++ b/controllers/encrypt.js @@ -1,7 +1,6 @@ 'use strict' -const path = require('path') -const RSA = require(path.join(__dirname, '/../lib/RSA')) +const RSA = require('../lib/RSA') module.exports = (req, res) => { const encryptedText = RSA.encrypt(req.params.text) diff --git a/controllers/handlePR.js b/controllers/handlePR.js index 80bc057f..d174e751 100644 --- a/controllers/handlePR.js +++ b/controllers/handlePR.js @@ -4,7 +4,7 @@ const config = require('../config') const GitHub = require('../lib/GitHub') const Staticman = require('../lib/Staticman') -module.exports = (repo, data) => { +module.exports = async (repo, data) => { const ua = config.get('analytics.uaTrackingId') ? require('universal-analytics')(config.get('analytics.uaTrackingId')) : null @@ -19,7 +19,9 @@ module.exports = (repo, data) => { token: config.get('githubToken') }) - return github.getReview(data.number).then((review) => { + try { + const review = await github.getReview(data.number) + if (review.sourceBranch.indexOf('staticman_')) { return null } @@ -32,33 +34,28 @@ module.exports = (repo, data) => { const bodyMatch = review.body.match(/(?:.*?)(?:.*?)/i) if (bodyMatch && (bodyMatch.length === 2)) { - try { - const parsedBody = JSON.parse(bodyMatch[1]) - const staticman = new Staticman(parsedBody.parameters) + const parsedBody = JSON.parse(bodyMatch[1]) + const staticman = new Staticman(parsedBody.parameters) - staticman.setConfigPath(parsedBody.configPath) - staticman.processMerge(parsedBody.fields, parsedBody.options) - .catch(err => Promise.reject(err)) - } catch (err) { - return Promise.reject(err) - } + staticman.setConfigPath(parsedBody.configPath) + await staticman.processMerge(parsedBody.fields, parsedBody.options) } } - return github.deleteBranch(review.sourceBranch) - }).then(response => { + const response = github.deleteBranch(review.sourceBranch) + if (ua) { ua.event('Hooks', 'Delete branch').send() } return response - }).catch(err => { + } catch (err) { console.log(err.stack || err) if (ua) { ua.event('Hooks', 'Delete branch error').send() } - return Promise.reject(err) - }) + throw err + } } diff --git a/controllers/process.js b/controllers/process.js index 9bcce71b..f87a8edb 100644 --- a/controllers/process.js +++ b/controllers/process.js @@ -7,77 +7,70 @@ const reCaptcha = require('express-recaptcha') const Staticman = require('../lib/Staticman') const universalAnalytics = require('universal-analytics') -function checkRecaptcha (staticman, req) { - return new Promise((resolve, reject) => { - staticman.getSiteConfig().then(siteConfig => { - if (!siteConfig.get('reCaptcha.enabled')) { - return resolve(false) - } +async function checkRecaptcha (staticman, req) { + const siteConfig = await staticman.getSiteConfig() - const reCaptchaOptions = req.body.options && req.body.options.reCaptcha + if (!siteConfig.get('reCaptcha.enabled')) { + return false + } - if (!reCaptchaOptions || !reCaptchaOptions.siteKey || !reCaptchaOptions.secret) { - return reject(errorHandler('RECAPTCHA_MISSING_CREDENTIALS')) - } + const reCaptchaOptions = req.body.options && req.body.options.reCaptcha - let decryptedSecret + if (!reCaptchaOptions || !reCaptchaOptions.siteKey || !reCaptchaOptions.secret) { + throw errorHandler('RECAPTCHA_MISSING_CREDENTIALS') + } - try { - decryptedSecret = staticman.decrypt(reCaptchaOptions.secret) - } catch (err) { - return reject(errorHandler('RECAPTCHA_CONFIG_MISMATCH')) - } + let decryptedSecret - if ( - reCaptchaOptions.siteKey !== siteConfig.get('reCaptcha.siteKey') || - decryptedSecret !== siteConfig.get('reCaptcha.secret') - ) { - return reject(errorHandler('RECAPTCHA_CONFIG_MISMATCH')) - } + try { + decryptedSecret = staticman.decrypt(reCaptchaOptions.secret) + } catch (err) { + throw errorHandler('RECAPTCHA_CONFIG_MISMATCH') + } + + if ( + reCaptchaOptions.siteKey !== siteConfig.get('reCaptcha.siteKey') || + decryptedSecret !== siteConfig.get('reCaptcha.secret') + ) { + throw errorHandler('RECAPTCHA_CONFIG_MISMATCH') + } - reCaptcha.init(reCaptchaOptions.siteKey, decryptedSecret) - reCaptcha.verify(req, err => { - if (err) { - return reject(errorHandler(err)) - } + reCaptcha.init(reCaptchaOptions.siteKey, decryptedSecret) + + return new Promise((resolve, reject) => { + reCaptcha.verify(req, err => { + if (err) { + return reject(errorHandler(err)) + } - return resolve(true) - }) - }).catch(err => reject(err)) + return resolve(true) + }) }) } function createConfigObject (apiVersion, property) { - let remoteConfig = {} - - if (apiVersion === '1') { - remoteConfig.file = '_config.yml' - remoteConfig.path = 'staticman' - } else { - remoteConfig.file = 'staticman.yml' - remoteConfig.path = property || '' - } - - return remoteConfig + return apiVersion === '1' + ? { file: '_config.yml', path: 'staticman' } + : { file: 'staticman.yml', path: property || '' } } -function process (staticman, req, res) { +async function process (staticman, req, res) { const ua = config.get('analytics.uaTrackingId') ? universalAnalytics(config.get('analytics.uaTrackingId')) : null const fields = req.query.fields || req.body.fields const options = req.query.options || req.body.options || {} - return staticman.processEntry(fields, options).then(data => { - sendResponse(res, { - redirect: data.redirect, - fields: data.fields - }) + const data = await staticman.processEntry(fields, options) - if (ua) { - ua.event('Entries', 'New entry').send() - } + sendResponse(res, { + redirect: data.redirect, + fields: data.fields }) + + if (ua) { + ua.event('Entries', 'New entry').send() + } } function sendResponse (res, data) { @@ -120,20 +113,23 @@ function sendResponse (res, data) { res.status(statusCode).send(payload) } -module.exports = (req, res, next) => { +module.exports = async (req, res, next) => { const staticman = new Staticman(req.params) staticman.setConfigPath() staticman.setIp(req.headers['x-forwarded-for'] || req.connection.remoteAddress) staticman.setUserAgent(req.headers['user-agent']) - return checkRecaptcha(staticman, req) - .then(usedRecaptcha => process(staticman, req, res)) - .catch(err => sendResponse(res, { + try { + await checkRecaptcha(staticman, req) + await process(staticman, req, res) + } catch (err) { + sendResponse(res, { err, redirect: req.body.options && req.body.options.redirect, redirectError: req.body.options && req.body.options.redirectError - })) + }) + } } module.exports.checkRecaptcha = checkRecaptcha diff --git a/coverage/cobertura-coverage.xml b/coverage/cobertura-coverage.xml index 9fb167bb..2fe81586 100644 --- a/coverage/cobertura-coverage.xml +++ b/coverage/cobertura-coverage.xml @@ -1,11 +1,11 @@ - + - /home/nick/Development/Javascript/staticman + C:\Users\Maciek\Workspace\staticman - + @@ -39,86 +39,86 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -129,62 +129,53 @@ - - - + - + - - - - - - + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - + + + + + + + + + + @@ -223,9 +214,9 @@ - + - + @@ -242,26 +233,11 @@ - - - - - - + - - - - - - - - - - @@ -278,41 +254,28 @@ + + - - - - + + + + - + - - - - - - - - - - - - - - - - + - + @@ -326,65 +289,45 @@ - - + + - - - - - - - - - + + + + + + + + + + - + - + - - - - - - + + + + + - + - - - - - - - - - - - - - - - - - - - - @@ -396,31 +339,29 @@ - - - - - - - - - + + + + + + + + - + - - - - - - - - - - + + + + + + + + + - + @@ -434,66 +375,41 @@ - + - - - - - - + - + - - - - - - + - + - + - - - - - - + - - - - - - - - - - - + - + - + @@ -505,7 +421,6 @@ - @@ -517,63 +432,58 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + - + - + @@ -585,39 +495,44 @@ - + - + - + - + - + + + + + + - + - + - + - + - + - + - + @@ -628,50 +543,45 @@ - - - - - - - - - - - + + + + + - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + - + + + - + - + - + - + - + @@ -684,14 +594,14 @@ - + - + - + - + @@ -771,24 +681,24 @@ - - - - - - - - - - + + + + + + + + + + - - + + - - + + @@ -810,10 +720,10 @@ - + - + @@ -961,11 +871,11 @@ - + - + - + @@ -1018,9 +928,9 @@ - + - + @@ -1045,11 +955,11 @@ - - - - - + + + + + @@ -1072,15 +982,15 @@ - + - + - + @@ -1097,21 +1007,21 @@ - + - + - + - + @@ -1120,94 +1030,74 @@ - - - - - - - - - - + + + + + + + + - + - + - + - + - + - + - - - - - - - + + + - - - - + + + + + - + - - - - - - - - - - - - - - - - + - + - + - + - + - + @@ -1215,18 +1105,19 @@ - - - - - - - - - + + + + + + + + + + - + @@ -1256,261 +1147,211 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -1531,366 +1372,310 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - + - - - - - - - - - - + + + + + + - - - - - - - - - + + + + + + + + - - + + - + - - - - - - - - - - + + + + + + + + - + - + - + @@ -1907,14 +1692,14 @@ - + - + @@ -1923,17 +1708,17 @@ - + - + - + @@ -1942,7 +1727,7 @@ - + @@ -1953,10 +1738,10 @@ - + - + @@ -1965,7 +1750,7 @@ - + @@ -1982,7 +1767,7 @@ - + diff --git a/lib/ErrorHandler.js b/lib/ErrorHandler.js index 5790bb9f..fa8f9e4f 100644 --- a/lib/ErrorHandler.js +++ b/lib/ErrorHandler.js @@ -18,80 +18,84 @@ class ApiError { } } -const ErrorHandler = function () { - this.ERROR_MESSAGES = { - 'missing-input-secret': 'reCAPTCHA: The secret parameter is missing', - 'invalid-input-secret': 'reCAPTCHA: The secret parameter is invalid or malformed', - 'missing-input-response': 'reCAPTCHA: The response parameter is missing', - 'invalid-input-response': 'reCAPTCHA: The response parameter is invalid or malformed', - 'RECAPTCHA_MISSING_CREDENTIALS': 'Missing reCAPTCHA API credentials', - 'RECAPTCHA_FAILED_DECRYPT': 'Could not decrypt reCAPTCHA secret', - 'RECAPTCHA_CONFIG_MISMATCH': 'reCAPTCHA options do not match Staticman config', - 'PARSING_ERROR': 'Error whilst parsing config file', - 'GITHUB_AUTH_TOKEN_MISSING': 'The site requires a valid GitHub authentication token to be supplied in the `options[github-token]` field' +class ErrorHandler { + static get ERROR_MESSAGES () { + return { + 'missing-input-secret': 'reCAPTCHA: The secret parameter is missing', + 'invalid-input-secret': 'reCAPTCHA: The secret parameter is invalid or malformed', + 'missing-input-response': 'reCAPTCHA: The response parameter is missing', + 'invalid-input-response': 'reCAPTCHA: The response parameter is invalid or malformed', + 'RECAPTCHA_MISSING_CREDENTIALS': 'Missing reCAPTCHA API credentials', + 'RECAPTCHA_FAILED_DECRYPT': 'Could not decrypt reCAPTCHA secret', + 'RECAPTCHA_CONFIG_MISMATCH': 'reCAPTCHA options do not match Staticman config', + 'PARSING_ERROR': 'Error whilst parsing config file', + 'GITHUB_AUTH_TOKEN_MISSING': 'The site requires a valid GitHub authentication token to be supplied in the `options[github-token]` field' + } } - this.ERROR_CODE_ALIASES = { - 'missing-input-secret': 'RECAPTCHA_MISSING_INPUT_SECRET', - 'invalid-input-secret': 'RECAPTCHA_INVALID_INPUT_SECRET', - 'missing-input-response': 'RECAPTCHA_MISSING_INPUT_RESPONSE', - 'invalid-input-response': 'RECAPTCHA_INVALID_INPUT_RESPONSE' + static get ERROR_CODE_ALIASES () { + return { + 'missing-input-secret': 'RECAPTCHA_MISSING_INPUT_SECRET', + 'invalid-input-secret': 'RECAPTCHA_INVALID_INPUT_SECRET', + 'missing-input-response': 'RECAPTCHA_MISSING_INPUT_RESPONSE', + 'invalid-input-response': 'RECAPTCHA_INVALID_INPUT_RESPONSE' + } } -} -ErrorHandler.prototype.getErrorCode = function (error) { - return this.ERROR_CODE_ALIASES[error] || error -} + getErrorCode (error) { + return ErrorHandler.ERROR_CODE_ALIASES[error] || error + } -ErrorHandler.prototype.getMessage = function (error) { - return this.ERROR_MESSAGES[error] -} + getMessage (error) { + return ErrorHandler.ERROR_MESSAGES[error] + } -ErrorHandler.prototype.log = function (err, instance) { - let parameters = {} - let prefix = '' + log (err, instance) { + let parameters = {} + let prefix = '' - if (instance) { - parameters = instance.getParameters() + if (instance) { + parameters = instance.getParameters() - prefix += `${parameters.username}/${parameters.repository}` - } + prefix += `${parameters.username}/${parameters.repository}` + } - console.log(`${prefix}`, err) -} + console.log(`${prefix}`, err) + } -ErrorHandler.prototype._save = function (errorCode, data = {}) { - const {err} = data - - if (err) { - err._smErrorCode = err._smErrorCode || errorCode - - // Re-wrap API request errors as these could expose - // request/response details that the user should not - // be allowed to see e.g. access tokens. - // `request-promise` is the primary offender here, - // but we similarly do not want others to leak too. - if ( - err instanceof StatusCodeError || - err instanceof RequestError || - err instanceof HttpError - ) { - const statusCode = err.statusCode || err.code - return new ApiError(err.message, statusCode, err._smErrorCode) + _save (errorCode, data = {}) { + const { err } = data + + if (err) { + err._smErrorCode = err._smErrorCode || errorCode + + // Re-wrap API request errors as these could expose + // request/response details that the user should not + // be allowed to see e.g. access tokens. + // `request-promise` is the primary offender here, + // but we similarly do not want others to leak too. + if ( + err instanceof StatusCodeError || + err instanceof RequestError || + err instanceof HttpError + ) { + const statusCode = err.statusCode || err.code + return new ApiError(err.message, statusCode, err._smErrorCode) + } + + return err } - return err - } + let payload = { + _smErrorCode: errorCode + } - let payload = { - _smErrorCode: errorCode - } + if (data.data) { + payload.data = data.data + } - if (data.data) { - payload.data = data.data + return payload } - - return payload } const errorHandler = new ErrorHandler() diff --git a/lib/Logger.js b/lib/Logger.js index 7aa6812e..12407989 100644 --- a/lib/Logger.js +++ b/lib/Logger.js @@ -3,30 +3,32 @@ const BunyanSlack = require('bunyan-slack') const path = require('path') const config = require(path.join(__dirname, '/../config')) -const Logger = function () { - let options = { - enabled: true, - level: 'info', - stream: process.stdout +class Logger { + constructor () { + let options = { + enabled: true, + level: 'info', + stream: process.stdout + } + + if (typeof config.get('logging.slackWebhook') === 'string') { + this.formatFn = t => '```\n' + t + '\n```' + + options.stream = new BunyanSlack({ + webhook_url: config.get('logging.slackWebhook') + }) + } + + logger.init(options) } - if (typeof config.get('logging.slackWebhook') === 'string') { - this.formatFn = t => '```\n' + t + '\n```' - - options.stream = new BunyanSlack({ - webhook_url: config.get('logging.slackWebhook') - }) - } - - logger.init(options) -} - -Logger.prototype.info = function (data) { - const formattedData = typeof this.formatFn === 'function' + info (data) { + const formattedData = typeof this.formatFn === 'function' ? this.formatFn(data) : data - logger.info(formattedData) + logger.info(formattedData) + } } const instance = new Logger() diff --git a/lib/Notification.js b/lib/Notification.js index 7b47a955..1eb95de7 100644 --- a/lib/Notification.js +++ b/lib/Notification.js @@ -3,44 +3,46 @@ const path = require('path') const config = require(path.join(__dirname, '/../config')) -const Notification = function (mailAgent) { - this.mailAgent = mailAgent -} +class Notification { + constructor (mailAgent) { + this.mailAgent = mailAgent + } -Notification.prototype._buildMessage = function (fields, options, data) { - return ` - - - Dear human,
-
- Someone replied to a comment you subscribed to${data.siteName ? ` on ${data.siteName}` : ''}.
-
- ${options.origin ? `Click here to see it.` : ''} If you do not wish to receive any further notifications for this thread, click here.
-
- #ftw,
- -- Staticman - - - ` -} + _buildMessage (fields, options, data) { + return ` + + + Dear human,
+
+ Someone replied to a comment you subscribed to${data.siteName ? ` on ${data.siteName}` : ''}.
+
+ ${options.origin ? `Click here to see it.` : ''} If you do not wish to receive any further notifications for this thread, click here.
+
+ #ftw,
+ -- Staticman + + + ` + } -Notification.prototype.send = function (to, fields, options, data) { - const subject = data.siteName ? `New reply on "${data.siteName}"` : 'New reply' + send (to, fields, options, data) { + const subject = data.siteName ? `New reply on "${data.siteName}"` : 'New reply' - return new Promise((resolve, reject) => { - this.mailAgent.messages().send({ - from: `Staticman <${config.get('email.fromAddress')}>`, - to, - subject, - html: this._buildMessage(fields, options, data) - }, (err, res) => { - if (err) { - return reject(err) - } + return new Promise((resolve, reject) => { + this.mailAgent.messages().send({ + from: `Staticman <${config.get('email.fromAddress')}>`, + to, + subject, + html: this._buildMessage(fields, options, data) + }, (err, res) => { + if (err) { + return reject(err) + } - return resolve(res) + return resolve(res) + }) }) - }) + } } module.exports = Notification diff --git a/lib/OAuth.js b/lib/OAuth.js index 96f6f6f1..39ab84c7 100644 --- a/lib/OAuth.js +++ b/lib/OAuth.js @@ -4,43 +4,44 @@ const config = require('../config') const request = require('request-promise') const errorHandler = require('./ErrorHandler') +/** + * Helper for getting API access tokens + * @param {github|gitlab} type type of token to get + * @param {*} code + * @param {*} clientId + * @param {*} clientSecret + * @param {*} redirectUri + */ +const _requestAccessToken = async (type, code, clientId, clientSecret, redirectUri) => { + try { + const res = await request({ + headers: { + 'Accept': 'application/json' + }, + json: true, + method: 'POST', + uri: config.get(`${type}AccessTokenUri`), + qs: { + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: type === 'gitlab' ? 'authorization_code' : undefined + } + }) + + return res.access_token + } catch (err) { + throw errorHandler(`${type.toUpperCase()}_AUTH_FAILED`, { err }) + } +} + const requestGitHubAccessToken = (code, clientId, clientSecret, redirectUri) => { - return request({ - headers: { - 'Accept': 'application/json' - }, - json: true, - method: 'POST', - uri: config.get('githubAccessTokenUri'), - qs: { - code, - client_id: clientId, - client_secret: clientSecret, - redirect_uri: redirectUri - } - }) - .then(res => res.access_token) - .catch(err => Promise.reject(errorHandler('GITHUB_AUTH_FAILED', {err}))) // eslint-disable-line handle-callback-err + return _requestAccessToken('github', code, clientId, clientSecret, redirectUri) } const requestGitLabAccessToken = (code, clientId, clientSecret, redirectUri) => { - return request({ - headers: { - 'Accept': 'application/json' - }, - json: true, - method: 'POST', - uri: config.get('gitlabAccessTokenUri'), - qs: { - code, - client_id: clientId, - client_secret: clientSecret, - grant_type: 'authorization_code', - redirect_uri: redirectUri - } - }) - .then(res => res.access_token) - .catch(err => Promise.reject(errorHandler('GITLAB_AUTH_FAILED', {err}))) // eslint-disable-line handle-callback-err + return _requestAccessToken('gitlab', code, clientId, clientSecret, redirectUri) } module.exports = { diff --git a/lib/Staticman.js b/lib/Staticman.js index d0b1f5f1..d6bdfbcd 100644 --- a/lib/Staticman.js +++ b/lib/Staticman.js @@ -17,228 +17,235 @@ const Transforms = require('./Transforms') const uuidv1 = require('uuid/v1') const yaml = require('js-yaml') -const Staticman = function (parameters) { - this.parameters = parameters - - const token = parameters.service === 'gitlab' - ? config.get('gitlabToken') - : config.get('githubToken') - - // Initialise the Git service API - this.git = gitFactory.create(parameters.service, { - username: parameters.username, - repository: parameters.repository, - branch: parameters.branch, - token - }) - - // Generate unique id - this.uid = uuidv1() - - this.rsa = new NodeRSA() - this.rsa.importKey(config.get('rsaPrivateKey')) -} - -Staticman.prototype._transforms = Transforms - -Staticman.prototype._applyInternalFields = function (data) { - let internalFields = { - _id: this.uid +class Staticman { + static get SUBJECT_REGEX () { return /{(.*?)}/g } + static get ESCAPED_MATCH_REGEX () { return /[-[\]/{}()*+?.\\^$|]/g } + static get requiredFields () { + return [ + 'allowedFields', + 'branch', + 'format', + 'path' + ] } - // Inject parent, if present - if (this.options.parent) { - internalFields._parent = this.options.parent - } + constructor (parameters) { + this.parameters = parameters - return Object.assign(internalFields, data) -} + const token = parameters.service === 'gitlab' + ? config.get('gitlabToken') + : config.get('githubToken') -Staticman.prototype._applyGeneratedFields = function (data) { - const generatedFields = this.siteConfig.get('generatedFields') + // Initialise the Git service API + this.git = gitFactory.create(parameters.service, { + username: parameters.username, + repository: parameters.repository, + branch: parameters.branch, + token + }) - if (!generatedFields) return data + // Generate unique id + this.uid = uuidv1() - Object.keys(generatedFields).forEach(field => { - const generatedField = generatedFields[field] + this.rsa = new NodeRSA() + this.rsa.importKey(config.get('rsaPrivateKey')) - if ((typeof generatedField === 'object') && (!(generatedField instanceof Array))) { - const options = generatedField.options || {} + this._transforms = Transforms + } - switch (generatedField.type) { - case 'date': - data[field] = this._createDate(options) + _applyInternalFields (data) { + let internalFields = { + _id: this.uid + } - break + // Inject parent, if present + if (this.options.parent) { + internalFields._parent = this.options.parent + } - // TODO: Remove 'github' when v2 API is no longer supported - case 'github': - case 'user': - if (this.gitUser && typeof options.property === 'string') { - data[field] = objectPath.get(this.gitUser, options.property) - } + return { ...internalFields, ...data } + } - break + _applyGeneratedFields (data) { + const generatedFields = this.siteConfig.get('generatedFields') - case 'slugify': - if ( - typeof options.field === 'string' && - typeof data[options.field] === 'string' - ) { - data[field] = slugify(data[options.field]).toLowerCase() - } + if (!generatedFields) return data - break - } - } else { - data[field] = generatedField - } - }) + Object.keys(generatedFields).forEach(field => { + const generatedField = generatedFields[field] - return data -} + if ((typeof generatedField === 'object') && (!(generatedField instanceof Array))) { + const options = generatedField.options || {} -Staticman.prototype._applyTransforms = function (fields) { - const transforms = this.siteConfig.get('transforms') + switch (generatedField.type) { + case 'date': + data[field] = this._createDate(options) - if (!transforms) return Promise.resolve(fields) + break - // This doesn't serve any purpose for now, but we might want to have - // asynchronous transforms in the future. - let queue = [] + // TODO: Remove 'github' when v2 API is no longer supported + case 'github': + case 'user': + if (this.gitUser && typeof options.property === 'string') { + data[field] = objectPath.get(this.gitUser, options.property) + } - Object.keys(transforms).forEach(field => { - if (!fields[field]) return + break - let transformNames = [].concat(transforms[field]) + case 'slugify': + if ( + typeof options.field === 'string' && + typeof data[options.field] === 'string' + ) { + data[field] = slugify(data[options.field]).toLowerCase() + } - transformNames.forEach(transformName => { - let transformFn = this._transforms[transformName] - if (transformFn) { - fields[field] = transformFn(fields[field]) + break + } + } else { + data[field] = generatedField } }) - }) - return Promise.all(queue).then((results) => { - return fields - }) -} + return data + } -Staticman.prototype._checkForSpam = function (fields) { - if (!this.siteConfig.get('akismet.enabled')) return Promise.resolve(fields) + async _applyTransforms (fields) { + const transforms = this.siteConfig.get('transforms') - return new Promise((resolve, reject) => { - const akismet = akismetApi.client({ - apiKey: config.get('akismet.apiKey'), - blog: config.get('akismet.site') - }) + if (!transforms) return fields - akismet.checkSpam({ - user_ip: this.ip, - user_agent: this.userAgent, - comment_type: this.siteConfig.get('akismet.type'), - comment_author: fields[this.siteConfig.get('akismet.author')], - comment_author_email: fields[this.siteConfig.get('akismet.authorEmail')], - comment_author_url: fields[this.siteConfig.get('akismet.authorUrl')], - comment_content: fields[this.siteConfig.get('akismet.content')] - }, (err, isSpam) => { - if (err) return reject(err) + // This doesn't serve any purpose for now, but we might want to have + // asynchronous transforms in the future. + let queue = [] - if (isSpam) return reject(errorHandler('IS_SPAM')) + Object.keys(transforms).forEach(field => { + if (!fields[field]) return - return resolve(fields) + let transformNames = [].concat(transforms[field]) + + transformNames.forEach(transformName => { + let transformFn = this._transforms[transformName] + if (transformFn) { + fields[field] = transformFn(fields[field]) + } + }) }) - }) -} -Staticman.prototype._checkAuth = function () { - // TODO: Remove when v2 API is no longer supported - if (this.parameters.version === '2') { - return this._checkAuthV2() + return Promise.all(queue).then((results) => { + return fields + }) } - if (!this.siteConfig.get('auth.required')) { - return Promise.resolve(false) - } + _checkForSpam (fields) { + if (!this.siteConfig.get('akismet.enabled')) return Promise.resolve(fields) - if (!this.options['auth-token']) { - return Promise.reject(errorHandler('AUTH_TOKEN_MISSING')) - } + return new Promise((resolve, reject) => { + const akismet = akismetApi.client({ + apiKey: config.get('akismet.apiKey'), + blog: config.get('akismet.site') + }) + + akismet.checkSpam({ + user_ip: this.ip, + user_agent: this.userAgent, + comment_type: this.siteConfig.get('akismet.type'), + comment_author: fields[this.siteConfig.get('akismet.author')], + comment_author_email: fields[this.siteConfig.get('akismet.authorEmail')], + comment_author_url: fields[this.siteConfig.get('akismet.authorUrl')], + comment_content: fields[this.siteConfig.get('akismet.content')] + }, (err, isSpam) => { + if (err) return reject(err) - const oauthToken = RSA.decrypt(this.options['auth-token']) + if (isSpam) return reject(errorHandler('IS_SPAM')) - if (!oauthToken) { - return Promise.reject(errorHandler('AUTH_TOKEN_INVALID')) + return resolve(fields) + }) + }) } - const git = gitFactory.create(this.options['auth-type'], {oauthToken}) + _checkAuth () { + // TODO: Remove when v2 API is no longer supported + if (this.parameters.version === '2') { + return this._checkAuthV2() + } - return git.getCurrentUser().then(user => { - this.gitUser = user - return true - }) -} + if (!this.siteConfig.get('auth.required')) { + return Promise.resolve(false) + } -// TODO: Remove when v2 API is no longer supported -Staticman.prototype._checkAuthV2 = function () { - if (!this.siteConfig.get('githubAuth.required')) { - return Promise.resolve(false) - } + if (!this.options['auth-token']) { + return Promise.reject(errorHandler('AUTH_TOKEN_MISSING')) + } - if (!this.options['github-token']) { - return Promise.reject(errorHandler('GITHUB_AUTH_TOKEN_MISSING')) - } + const oauthToken = RSA.decrypt(this.options['auth-token']) - const oauthToken = RSA.decrypt(this.options['github-token']) + if (!oauthToken) { + return Promise.reject(errorHandler('AUTH_TOKEN_INVALID')) + } - if (!oauthToken) { - return Promise.reject(errorHandler('GITHUB_AUTH_TOKEN_INVALID')) + const git = gitFactory.create(this.options['auth-type'], {oauthToken}) + + return git.getCurrentUser().then(user => { + this.gitUser = user + return true + }) } - const git = gitFactory.create('github', {oauthToken}) + // TODO: Remove when v2 API is no longer supported + _checkAuthV2 () { + if (!this.siteConfig.get('githubAuth.required')) { + return Promise.resolve(false) + } - return git.api.users.get({}).then(({data}) => { - this.gitUser = data - return true - }) -} + if (!this.options['github-token']) { + return Promise.reject(errorHandler('GITHUB_AUTH_TOKEN_MISSING')) + } -Staticman.prototype._createDate = function (options) { - options = options || {} + const oauthToken = RSA.decrypt(this.options['github-token']) - const date = new Date() + if (!oauthToken) { + return Promise.reject(errorHandler('GITHUB_AUTH_TOKEN_INVALID')) + } - switch (options.format) { - case 'timestamp': - return date.getTime() + const git = gitFactory.create('github', {oauthToken}) - case 'timestamp-seconds': - return Math.floor(date.getTime() / 1000) + return git.api.users.get({}).then(({data}) => { + this.gitUser = data + return true + }) + } + + _createDate (options) { + options = options || {} + + const date = new Date() + + switch (options.format) { + case 'timestamp': + return date.getTime() - case 'iso8601': - default: - return date.toISOString() + case 'timestamp-seconds': + return Math.floor(date.getTime() / 1000) + + case 'iso8601': + default: + return date.toISOString() + } } -} -Staticman.prototype._createFile = function (fields) { - return new Promise((resolve, reject) => { + async _createFile (fields) { switch (this.siteConfig.get('format').toLowerCase()) { case 'json': - return resolve(JSON.stringify(fields)) + return JSON.stringify(fields) case 'yaml': - case 'yml': - try { - const output = yaml.safeDump(fields) - - return resolve(output) - } catch (err) { - return reject(err) - } + case 'yml': { + const output = yaml.safeDump(fields) - case 'frontmatter': + return output + } + case 'frontmatter': { const transforms = this.siteConfig.get('transforms') const contentField = transforms && Object.keys(transforms).find(field => { @@ -246,7 +253,7 @@ Staticman.prototype._createFile = function (fields) { }) if (!contentField) { - return reject(errorHandler('NO_FRONTMATTER_CONTENT_TRANSFORM')) + throw errorHandler('NO_FRONTMATTER_CONTENT_TRANSFORM') } const content = fields[contentField] @@ -254,354 +261,317 @@ Staticman.prototype._createFile = function (fields) { delete attributeFields[contentField] - try { - const output = `---\n${yaml.safeDump(attributeFields)}---\n${content}\n` - - return resolve(output) - } catch (err) { - return reject(err) - } + const output = `---\n${yaml.safeDump(attributeFields)}---\n${content}\n` + return output + } default: - return reject(errorHandler('INVALID_FORMAT')) + throw errorHandler('INVALID_FORMAT') } - }) -} + } -Staticman.prototype._generateReviewBody = function (fields) { - let table = [ - ['Field', 'Content'] - ] + _generateReviewBody (fields) { + const table = [ + ['Field', 'Content'], + ...Object.entries(fields) + ] - Object.keys(fields).forEach(field => { - table.push([field, fields[field]]) - }) + let message = this.siteConfig.get('pullRequestBody') + markdownTable(table) - let message = this.siteConfig.get('pullRequestBody') + markdownTable(table) + if (this.siteConfig.get('notifications.enabled')) { + const notificationsPayload = { + configPath: this.configPath, + fields, + options: this.options, + parameters: this.parameters + } - if (this.siteConfig.get('notifications.enabled')) { - const notificationsPayload = { - configPath: this.configPath, - fields, - options: this.options, - parameters: this.parameters + message += `\n\n` } - message += `\n\n` + return message } - return message -} + _getNewFilePath (data) { + const configFilename = this.siteConfig.get('filename') + const filename = (configFilename && configFilename.length) + ? this._resolvePlaceholders(configFilename, { + fields: data, + options: this.options + }) + : this.uid -Staticman.prototype._getNewFilePath = function (data) { - const configFilename = this.siteConfig.get('filename') - const filename = (configFilename && configFilename.length) - ? this._resolvePlaceholders(configFilename, { + let path = this._resolvePlaceholders(this.siteConfig.get('path'), { fields: data, options: this.options }) - : this.uid - - let path = this._resolvePlaceholders(this.siteConfig.get('path'), { - fields: data, - options: this.options - }) - // Remove trailing slash, if existing - if (path.slice(-1) === '/') { - path = path.slice(0, -1) - } + // Remove trailing slash, if existing + if (path.slice(-1) === '/') { + path = path.slice(0, -1) + } - const extension = this.siteConfig.get('extension').length - ? this.siteConfig.get('extension') - : this._getExtensionForFormat(this.siteConfig.get('format')) + const extension = this.siteConfig.get('extension').length + ? this.siteConfig.get('extension') + : this._getExtensionForFormat(this.siteConfig.get('format')) - return `${path}/${filename}.${extension}` -} + return `${path}/${filename}.${extension}` + } -Staticman.prototype._getExtensionForFormat = function (format) { - switch (format.toLowerCase()) { - case 'json': - return 'json' + _getExtensionForFormat (format) { + switch (format.toLowerCase()) { + case 'json': + return 'json' - case 'yaml': - case 'yml': - return 'yml' + case 'yaml': + case 'yml': + return 'yml' - case 'frontmatter': - return 'md' + case 'frontmatter': + return 'md' + } } -} -Staticman.prototype._initialiseSubscriptions = function () { - if (!this.siteConfig.get('notifications.enabled')) return null + _initialiseSubscriptions () { + if (!this.siteConfig.get('notifications.enabled')) return null - // Initialise Mailgun - const mailgun = Mailgun({ - apiKey: this.siteConfig.get('notifications.apiKey') || config.get('email.apiKey'), - domain: this.siteConfig.get('notifications.domain') || config.get('email.domain') - }) - - // Initialise SubscriptionsManager - const subscriptions = new SubscriptionsManager(this.parameters, this.git, mailgun) + // Initialise Mailgun + const mailgun = Mailgun({ + apiKey: this.siteConfig.get('notifications.apiKey') || config.get('email.apiKey'), + domain: this.siteConfig.get('notifications.domain') || config.get('email.domain') + }) - return subscriptions -} + // Initialise SubscriptionsManager + return new SubscriptionsManager(this.parameters, this.git, mailgun) + } -Staticman.prototype._resolvePlaceholders = function (subject, baseObject) { - const matches = subject.match(/{(.*?)}/g) + _resolvePlaceholders (subject, baseObject) { + const matches = subject.match(Staticman.SUBJECT_REGEX) - if (!matches) return subject + if (!matches) return subject - matches.forEach((match) => { - const escapedMatch = match.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&') - const property = match.slice(1, -1) + matches.forEach((match) => { + const escapedMatch = match.replace(Staticman.ESCAPED_MATCH_REGEX, '\\$&') + const property = match.slice(1, -1) - let newText + let newText - switch (property) { - case '@timestamp': - newText = new Date().getTime() + switch (property) { + case '@timestamp': + newText = new Date().getTime() - break + break - case '@id': - newText = this.uid + case '@id': + newText = this.uid - break + break - default: - const timeIdentifier = '@date:' + default: + const timeIdentifier = '@date:' - if (property.indexOf(timeIdentifier) === 0) { - const timePattern = property.slice(timeIdentifier.length) + if (property.indexOf(timeIdentifier) === 0) { + const timePattern = property.slice(timeIdentifier.length) - newText = moment().format(timePattern) - } else { - newText = objectPath.get(baseObject, property) || '' - } - } - - subject = subject.replace(new RegExp(escapedMatch, 'g'), newText) - }) + newText = moment().format(timePattern) + } else { + newText = objectPath.get(baseObject, property) || '' + } + } - return subject -} + subject = subject.replace(new RegExp(escapedMatch, 'g'), newText) + }) -Staticman.prototype._validateConfig = function (config) { - if (!config) { - return errorHandler('MISSING_CONFIG_BLOCK') + return subject } - const requiredFields = [ - 'allowedFields', - 'branch', - 'format', - 'path' - ] + _validateConfig (config) { + if (!config) { + return errorHandler('MISSING_CONFIG_BLOCK') + } - let missingFields = [] + const missingFields = Staticman.requiredFields.filter(requiredField => + objectPath.get(config, requiredField) === undefined) - // Checking for missing required fields - requiredFields.forEach(requiredField => { - if (objectPath.get(config, requiredField) === undefined) { - missingFields.push(requiredField) + if (missingFields.length) { + return errorHandler('MISSING_CONFIG_FIELDS', { + data: missingFields + }) } - }) - if (missingFields.length) { - return errorHandler('MISSING_CONFIG_FIELDS', { - data: missingFields - }) - } + this.siteConfig = SiteConfig(config, this.rsa) - this.siteConfig = SiteConfig(config, this.rsa) + return null + } - return null -} + _validateFields (fields) { + Object.keys(fields).forEach(field => { + // Trim fields + if (typeof fields[field] === 'string') { + fields[field] = fields[field].trim() + } + }) -Staticman.prototype._validateFields = function (fields) { - let missingRequiredFields = [] - let invalidFields = [] + const missingRequiredFields = this.siteConfig.get('requiredFields') + .filter(field => fields[field] === undefined || fields[field] === '') - Object.keys(fields).forEach(field => { - // Check for any invalid fields - if ((this.siteConfig.get('allowedFields').indexOf(field) === -1) && (fields[field] !== '')) { - invalidFields.push(field) - } + const invalidFields = Object.keys(fields) + .filter(field => this.siteConfig.get('allowedFields').indexOf(field) === -1 && fields[field] !== '') - // Trim fields - if (typeof fields[field] === 'string') { - fields[field] = fields[field].trim() + if (missingRequiredFields.length) { + return errorHandler('MISSING_REQUIRED_FIELDS', { + data: missingRequiredFields + }) } - }) - // Check for missing required fields - this.siteConfig.get('requiredFields').forEach(field => { - if ((fields[field] === undefined) || (fields[field] === '')) { - missingRequiredFields.push(field) + if (invalidFields.length) { + return errorHandler('INVALID_FIELDS', { + data: invalidFields + }) } - }) - if (missingRequiredFields.length) { - return errorHandler('MISSING_REQUIRED_FIELDS', { - data: missingRequiredFields - }) + return null } - if (invalidFields.length) { - return errorHandler('INVALID_FIELDS', { - data: invalidFields - }) + decrypt (encrypted) { + return this.rsa.decrypt(encrypted, 'utf8') } - return null -} - -Staticman.prototype.decrypt = function (encrypted) { - return this.rsa.decrypt(encrypted, 'utf8') -} + getParameters () { + return this.parameters + } -Staticman.prototype.getParameters = function () { - return this.parameters -} + async getSiteConfig (force) { + if (this.siteConfig && !force) return this.siteConfig -Staticman.prototype.getSiteConfig = function (force) { - if (this.siteConfig && !force) return Promise.resolve(this.siteConfig) + if (!this.configPath) throw errorHandler('NO_CONFIG_PATH') - if (!this.configPath) return Promise.reject(errorHandler('NO_CONFIG_PATH')) + const data = await this.git.readFile(this.configPath.file) - return this.git.readFile(this.configPath.file).then(data => { const config = objectPath.get(data, this.configPath.path) const validationErrors = this._validateConfig(config) if (validationErrors) { - return Promise.reject(validationErrors) + throw validationErrors } if (config.branch !== this.parameters.branch) { - return Promise.reject(errorHandler('BRANCH_MISMATCH')) + throw errorHandler('BRANCH_MISMATCH') } return this.siteConfig - }) -} + } -Staticman.prototype.processEntry = function (fields, options) { - this.fields = Object.assign({}, fields) - this.options = Object.assign({}, options) - - return this.getSiteConfig().then(config => { - return this._checkAuth() - }).then(() => { - return this._checkForSpam(fields) - }).then(fields => { - // Validate fields - const fieldErrors = this._validateFields(fields) - - if (fieldErrors) return Promise.reject(fieldErrors) - - // Add generated fields - fields = this._applyGeneratedFields(fields) - - // Apply transforms - return this._applyTransforms(fields) - }).then(transformedFields => { - return this._applyInternalFields(transformedFields) - }).then(extendedFields => { - // Create file - return this._createFile(extendedFields) - }).then(data => { - const filePath = this._getNewFilePath(fields) - const subscriptions = this._initialiseSubscriptions() - const commitMessage = this._resolvePlaceholders(this.siteConfig.get('commitMessage'), { - fields, - options - }) + async processEntry (fields, options) { + this.fields = { ...fields } + this.options = { ...options } - // Subscribe user, if applicable - if (subscriptions && options.parent && options.subscribe && this.fields[options.subscribe]) { - subscriptions.set(options.parent, this.fields[options.subscribe]).catch(err => { - console.log(err.stack || err) - }) - } + try { + await this.getSiteConfig() + await this._checkAuth() + const checkedFields = await this._checkForSpam(fields) - if (this.siteConfig.get('moderation')) { - const newBranch = 'staticman_' + this.uid - - return this.git.writeFileAndSendReview( - filePath, - data, - newBranch, - commitMessage, - this._generateReviewBody(fields) - ) - } else if (subscriptions && options.parent) { - subscriptions.send(options.parent, fields, options, this.siteConfig) - } + // Validate fields + const fieldErrors = this._validateFields(fields) - return this.git.writeFile( - filePath, - data, - this.parameters.branch, - commitMessage - ) - }).then(result => { - return { - fields: fields, - redirect: options.redirect ? options.redirect : false - } - }).catch(err => { - return Promise.reject(errorHandler('ERROR_PROCESSING_ENTRY', { - err, - instance: this - })) - }) -} + if (fieldErrors) throw fieldErrors -Staticman.prototype.processMerge = function (fields, options) { - this.fields = Object.assign({}, fields) - this.options = Object.assign({}, options) + // Add generated fields + const generatedFields = this._applyGeneratedFields(checkedFields) - return this.getSiteConfig().then(config => { - const subscriptions = this._initialiseSubscriptions() + // Apply transforms + const transformedFields = await this._applyTransforms(generatedFields) + const extendedFields = await this._applyInternalFields(transformedFields) - return subscriptions.send(options.parent, fields, options, this.siteConfig) - }).catch(err => { - return Promise.reject(errorHandler('ERROR_PROCESSING_MERGE', { - err, - instance: this - })) - }) -} + // Create file + const data = await this._createFile(extendedFields) -Staticman.prototype.setConfigPath = function (configPath) { - // Default config path - if (!configPath) { - if (this.parameters.version === '1') { - this.configPath = { - file: '_config.yml', - path: 'staticman' + const filePath = this._getNewFilePath(fields) + const subscriptions = this._initialiseSubscriptions() + const commitMessage = this._resolvePlaceholders(this.siteConfig.get('commitMessage'), { + fields, + options + }) + + // Subscribe user, if applicable + if (subscriptions && options.parent && options.subscribe && this.fields[options.subscribe]) { + try { + await subscriptions.set(options.parent, this.fields[options.subscribe]) + } catch (err) { + console.log(err.stack || err) + } } - } else { - this.configPath = { - file: 'staticman.yml', - path: this.parameters.property || '' + + if (this.siteConfig.get('moderation')) { + const newBranch = 'staticman_' + this.uid + + await this.git.writeFileAndSendReview( + filePath, + data, + newBranch, + commitMessage, + this._generateReviewBody(fields) + ) + } else { + if (subscriptions && options.parent) { + subscriptions.send(options.parent, fields, options, this.siteConfig) + } + + await this.git.writeFile( + filePath, + data, + this.parameters.branch, + commitMessage + ) + } + + return { + fields: fields, + redirect: options.redirect ? options.redirect : false } + } catch (err) { + throw errorHandler('ERROR_PROCESSING_ENTRY', { + err, + instance: this + }) } + } + + async processMerge (fields, options) { + this.fields = { ...fields } + this.options = { ...options } + + try { + await this.getSiteConfig() + const subscriptions = this._initialiseSubscriptions() - return + return subscriptions.send(options.parent, fields, options, this.siteConfig) + } catch (err) { + throw errorHandler('ERROR_PROCESSING_MERGE', { + err, + instance: this + }) + } } - this.configPath = configPath -} + setConfigPath (configPath) { + if (!configPath) { + this.configPath = this.parameters.version === '1' + ? { file: '_config.yml', path: 'staticman' } + : { file: 'staticman.yml', path: this.parameters.property || '' } + return + } -Staticman.prototype.setIp = function (ip) { - this.ip = ip -} + this.configPath = configPath + } + + setIp (ip) { + this.ip = ip + } -Staticman.prototype.setUserAgent = function (userAgent) { - this.userAgent = userAgent + setUserAgent (userAgent) { + this.userAgent = userAgent + } } module.exports = Staticman diff --git a/lib/SubscriptionsManager.js b/lib/SubscriptionsManager.js index fdfc66a0..2da1f676 100644 --- a/lib/SubscriptionsManager.js +++ b/lib/SubscriptionsManager.js @@ -3,38 +3,40 @@ const md5 = require('md5') const Notification = require('./Notification') -const SubscriptionsManager = function (parameters, dataStore, mailAgent) { - this.parameters = parameters - this.dataStore = dataStore - this.mailAgent = mailAgent -} +class SubscriptionsManager { + constructor (parameters, dataStore, mailAgent) { + this.parameters = parameters + this.dataStore = dataStore + this.mailAgent = mailAgent + } -SubscriptionsManager.prototype._getListAddress = function (entryId) { - const compoundId = md5(`${this.parameters.username}-${this.parameters.repository}-${entryId}`) + _getListAddress (entryId) { + const compoundId = md5(`${this.parameters.username}-${this.parameters.repository}-${entryId}`) - return `${compoundId}@${this.mailAgent.domain}` -} + return `${compoundId}@${this.mailAgent.domain}` + } -SubscriptionsManager.prototype._get = function (entryId) { - const listAddress = this._getListAddress(entryId) + _get (entryId) { + const listAddress = this._getListAddress(entryId) - return new Promise((resolve, reject) => { - this.mailAgent.lists(listAddress).info((err, value) => { - if (err && (err.statusCode !== 404)) { - return reject(err) - } + return new Promise((resolve, reject) => { + this.mailAgent.lists(listAddress).info((err, value) => { + if (err && (err.statusCode !== 404)) { + return reject(err) + } - if (err || !value || !value.list) { - return resolve(null) - } + if (err || !value || !value.list) { + return resolve(null) + } - return resolve(listAddress) + return resolve(listAddress) + }) }) - }) -} + } + + async send (entryId, fields, options, siteConfig) { + const list = await this._get(entryId) -SubscriptionsManager.prototype.send = function (entryId, fields, options, siteConfig) { - return this._get(entryId).then(list => { if (list) { const notifications = new Notification(this.mailAgent) @@ -42,40 +44,35 @@ SubscriptionsManager.prototype.send = function (entryId, fields, options, siteCo siteName: siteConfig.get('name') }) } - }) -} - -SubscriptionsManager.prototype.set = function (entryId, email) { - const listAddress = this._getListAddress(entryId) + } - return new Promise((resolve, reject) => { - let queue = [] + async set (entryId, email) { + const listAddress = this._getListAddress(entryId) + const list = await this._get(entryId) - return this._get(entryId).then(list => { - if (!list) { - queue.push(new Promise((resolve, reject) => { - this.mailAgent.lists().create({ - address: listAddress - }, (err, result) => { - if (err) return reject(err) - - return resolve(result) - }) - })) - } - - return Promise.all(queue).then(() => { - this.mailAgent.lists(listAddress).members().create({ - address: email + if (!list) { + await new Promise((resolve, reject) => { + this.mailAgent.lists().create({ + address: listAddress }, (err, result) => { - // A 400 is fine-ish, means the address already exists - if (err && (err.statusCode !== 400)) return reject(err) + if (err) return reject(err) return resolve(result) }) }) + } + + return new Promise((resolve, reject) => { + this.mailAgent.lists(listAddress).members().create({ + address: email + }, (err, result) => { + // A 400 is fine-ish, means the address already exists + if (err && (err.statusCode !== 400)) return reject(err) + + return resolve(result) + }) }) - }) + } } module.exports = SubscriptionsManager diff --git a/server.js b/server.js index 34838e60..095160cf 100644 --- a/server.js +++ b/server.js @@ -7,184 +7,186 @@ const ExpressBrute = require('express-brute') const GithubWebHook = require('express-github-webhook') const objectPath = require('object-path') -const StaticmanAPI = function () { - this.controllers = { - connect: require('./controllers/connect'), - encrypt: require('./controllers/encrypt'), - auth: require('./controllers/auth'), - handlePR: require('./controllers/handlePR'), - home: require('./controllers/home'), - process: require('./controllers/process') +class StaticmanAPI { + constructor () { + this.controllers = { + connect: require('./controllers/connect'), + encrypt: require('./controllers/encrypt'), + auth: require('./controllers/auth'), + handlePR: require('./controllers/handlePR'), + home: require('./controllers/home'), + process: require('./controllers/process') + } + + this.server = express() + this.server.use(bodyParser.json()) + this.server.use(bodyParser.urlencoded({ + extended: true + // type: '*' + })) + + this.initialiseWebhookHandler() + this.initialiseCORS() + this.initialiseBruteforceProtection() + this.initialiseRoutes() } - this.server = express() - this.server.use(bodyParser.json()) - this.server.use(bodyParser.urlencoded({ - extended: true - // type: '*' - })) - - this.initialiseWebhookHandler() - this.initialiseCORS() - this.initialiseBruteforceProtection() - this.initialiseRoutes() -} + initialiseBruteforceProtection () { + const store = new ExpressBrute.MemoryStore() -StaticmanAPI.prototype.initialiseBruteforceProtection = function () { - const store = new ExpressBrute.MemoryStore() + this.bruteforce = new ExpressBrute(store) + } - this.bruteforce = new ExpressBrute(store) -} + initialiseCORS () { + this.server.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') -StaticmanAPI.prototype.initialiseCORS = function () { - this.server.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') + next() + }) + } - next() - }) -} + initialiseRoutes () { + // Route: connect + this.server.get( + '/v:version/connect/:username/:repository', + this.bruteforce.prevent, + this.requireApiVersion([1, 2]), + this.controllers.connect + ) + + this.server.get( + '/v:version/connect/:service/:username/:repository', + this.bruteforce.prevent, + this.requireApiVersion([3]), + this.requireService(['github']), + this.controllers.connect + ) + + // Route: process + this.server.post( + '/v:version/entry/:username/:repository/:branch', + this.bruteforce.prevent, + this.requireApiVersion([1, 2]), + this.requireParams(['fields']), + this.controllers.process + ) + + this.server.post( + '/v:version/entry/:username/:repository/:branch/:property', + this.bruteforce.prevent, + this.requireApiVersion([2]), + this.requireParams(['fields']), + this.controllers.process + ) + + this.server.post( + '/v:version/entry/:service/:username/:repository/:branch/:property', + this.bruteforce.prevent, + this.requireApiVersion([3]), + this.requireService(['github', 'gitlab']), + this.requireParams(['fields']), + this.controllers.process + ) + + // Route: encrypt + this.server.get( + '/v:version/encrypt/:text', + this.bruteforce.prevent, + this.requireApiVersion([2, 3]), + this.controllers.encrypt + ) + + // Route: oauth + this.server.get( + '/v:version/auth/:service/:username/:repository/:branch/:property', + this.bruteforce.prevent, + this.requireApiVersion([2, 3]), + this.requireService(['github', 'gitlab']), + this.controllers.auth + ) + + // Route: root + this.server.get( + '/', + this.controllers.home + ) + } -StaticmanAPI.prototype.initialiseRoutes = function () { - // Route: connect - this.server.get( - '/v:version/connect/:username/:repository', - this.bruteforce.prevent, - this.requireApiVersion([1, 2]), - this.controllers.connect - ) - - this.server.get( - '/v:version/connect/:service/:username/:repository', - this.bruteforce.prevent, - this.requireApiVersion([3]), - this.requireService(['github']), - this.controllers.connect - ) - - // Route: process - this.server.post( - '/v:version/entry/:username/:repository/:branch', - this.bruteforce.prevent, - this.requireApiVersion([1, 2]), - this.requireParams(['fields']), - this.controllers.process - ) - - this.server.post( - '/v:version/entry/:username/:repository/:branch/:property', - this.bruteforce.prevent, - this.requireApiVersion([2]), - this.requireParams(['fields']), - this.controllers.process - ) - - this.server.post( - '/v:version/entry/:service/:username/:repository/:branch/:property', - this.bruteforce.prevent, - this.requireApiVersion([3]), - this.requireService(['github', 'gitlab']), - this.requireParams(['fields']), - this.controllers.process - ) - - // Route: encrypt - this.server.get( - '/v:version/encrypt/:text', - this.bruteforce.prevent, - this.requireApiVersion([2, 3]), - this.controllers.encrypt - ) - - // Route: oauth - this.server.get( - '/v:version/auth/:service/:username/:repository/:branch/:property', - this.bruteforce.prevent, - this.requireApiVersion([2, 3]), - this.requireService(['github', 'gitlab']), - this.controllers.auth - ) - - // Route: root - this.server.get( - '/', - this.controllers.home - ) -} + initialiseWebhookHandler () { + const webhookHandler = GithubWebHook({ + path: '/v1/webhook' + }) -StaticmanAPI.prototype.initialiseWebhookHandler = function () { - const webhookHandler = GithubWebHook({ - path: '/v1/webhook' - }) + webhookHandler.on('pull_request', this.controllers.handlePR) - webhookHandler.on('pull_request', this.controllers.handlePR) + this.server.use(webhookHandler) + } - this.server.use(webhookHandler) -} + requireApiVersion (versions) { + return (req, res, next) => { + const versionMatch = versions.some(version => { + return version.toString() === req.params.version + }) -StaticmanAPI.prototype.requireApiVersion = function (versions) { - return (req, res, next) => { - const versionMatch = versions.some(version => { - return version.toString() === req.params.version - }) + if (!versionMatch) { + return res.status(400).send({ + success: false, + errorCode: 'INVALID_VERSION' + }) + } - if (!versionMatch) { - return res.status(400).send({ - success: false, - errorCode: 'INVALID_VERSION' - }) + return next() } - - return next() } -} -StaticmanAPI.prototype.requireService = function (services) { - return (req, res, next) => { - const serviceMatch = services.some(service => service === req.params.service) + requireService (services) { + return (req, res, next) => { + const serviceMatch = services.some(service => service === req.params.service) - if (!serviceMatch) { - return res.status(400).send({ - success: false, - errorCode: 'INVALID_SERVICE' - }) - } + if (!serviceMatch) { + return res.status(400).send({ + success: false, + errorCode: 'INVALID_SERVICE' + }) + } - return next() + return next() + } } -} -StaticmanAPI.prototype.requireParams = function (params) { - return function (req, res, next) { - let missingParams = [] + requireParams (params) { + return function (req, res, next) { + let missingParams = [] + + params.forEach(param => { + if ( + objectPath.get(req.query, param) === undefined && + objectPath.get(req.body, param) === undefined + ) { + missingParams.push(param) + } + }) - params.forEach(param => { - if ( - objectPath.get(req.query, param) === undefined && - objectPath.get(req.body, param) === undefined - ) { - missingParams.push(param) + if (missingParams.length) { + return res.status(500).send({ + success: false, + errorCode: 'MISSING_PARAMS', + data: missingParams + }) } - }) - if (missingParams.length) { - return res.status(500).send({ - success: false, - errorCode: 'MISSING_PARAMS', - data: missingParams - }) + return next() } - - return next() } -} -StaticmanAPI.prototype.start = function (callback) { - const callbackFn = typeof callback === 'function' - ? callback.call(this, config.get('port')) - : null + start (callback) { + const callbackFn = typeof callback === 'function' + ? callback.call(this, config.get('port')) + : null - this.server.listen(config.get('port'), callbackFn) + this.server.listen(config.get('port'), callbackFn) + } } module.exports = StaticmanAPI diff --git a/test/helpers/CatchAllApiMock.js b/test/helpers/CatchAllApiMock.js index a2652d95..4815ed7f 100644 --- a/test/helpers/CatchAllApiMock.js +++ b/test/helpers/CatchAllApiMock.js @@ -1,21 +1,23 @@ const nock = require('nock') -const CatchAllApiMock = function (callback) { - this.NUM_MOCKS = 7 +class CatchAllApiMock { + constructor (callback) { + this.NUM_MOCKS = 7 - this.mock = nock(/api\.github\.com/) - .persist() - .filteringPath(() => '/').delete('/').reply(200, callback) - .filteringPath(() => '/').get('/').reply(200, callback) - .filteringPath(() => '/').head('/').reply(200, callback) - .filteringPath(() => '/').merge('/').reply(200, callback) - .filteringPath(() => '/').patch('/').reply(200, callback) - .filteringPath(() => '/').post('/').reply(200, callback) - .filteringPath(() => '/').put('/').reply(200, callback) -} + this.mock = nock(/api\.github\.com/) + .persist() + .filteringPath(() => '/').delete('/').reply(200, callback) + .filteringPath(() => '/').get('/').reply(200, callback) + .filteringPath(() => '/').head('/').reply(200, callback) + .filteringPath(() => '/').merge('/').reply(200, callback) + .filteringPath(() => '/').patch('/').reply(200, callback) + .filteringPath(() => '/').post('/').reply(200, callback) + .filteringPath(() => '/').put('/').reply(200, callback) + } -CatchAllApiMock.prototype.hasIntercepted = function () { - return this.mock.pendingMocks().length < this.NUM_MOCKS + hasIntercepted () { + return this.mock.pendingMocks().length < this.NUM_MOCKS + } } module.exports = CatchAllApiMock diff --git a/test/helpers/Config.js b/test/helpers/Config.js index 65b68684..f1f1f8e3 100644 --- a/test/helpers/Config.js +++ b/test/helpers/Config.js @@ -1,22 +1,24 @@ const objectPath = require('object-path') const yaml = require('js-yaml') -const Config = function (rawContent) { - this.data = yaml.safeLoad(rawContent, 'utf8') -} - -Config.prototype.get = function (key) { - if (key) { - return objectPath.get(this.data, key) +class Config { + constructor (rawContent) { + this.data = yaml.safeLoad(rawContent, 'utf8') } - - return this.data -} -Config.prototype.set = function (key, value) { - this.data = objectPath.set(this.data, key, value) + get (key) { + if (key) { + return objectPath.get(this.data, key) + } + + return this.data + } - return this.data + set (key, value) { + this.data = objectPath.set(this.data, key, value) + + return this.data + } } module.exports = Config diff --git a/test/unit/controllers/auth.test.js b/test/unit/controllers/auth.test.js index 61e2fdfe..6875a218 100644 --- a/test/unit/controllers/auth.test.js +++ b/test/unit/controllers/auth.test.js @@ -4,8 +4,8 @@ const nock = require('nock') const Staticman = require('./../../../lib/Staticman') const User = require('../../../lib/models/User') -Staticman.prototype.getSiteConfig = function () { - return Promise.resolve(helpers.getConfig()) +Staticman.prototype.getSiteConfig = async function () { + return helpers.getConfig() } let req diff --git a/test/unit/lib/Staticman.test.js b/test/unit/lib/Staticman.test.js index 6a60d196..550c116e 100644 --- a/test/unit/lib/Staticman.test.js +++ b/test/unit/lib/Staticman.test.js @@ -1394,6 +1394,8 @@ describe('Staticman interface', () => { }) test('validates fields, throwing an error if validation fails', () => { + jest.unmock('../../../lib/GitHub') // otherwise git.writeFile is undefined on first run + const Staticman = require('./../../../lib/Staticman') const staticman = new Staticman(mockParameters) const fields = mockHelpers.getFields() diff --git a/test/utils/coverage.svg b/test/utils/coverage.svg index 02d44a70..ea7445da 100644 --- a/test/utils/coverage.svg +++ b/test/utils/coverage.svg @@ -1 +1 @@ - coveragecoverage82%82% \ No newline at end of file + coveragecoverage82%82% \ No newline at end of file