UNPKG

ghost

Version:

The professional publishing platform

399 lines (340 loc) 14.2 kB
const _ = require('lodash'); const tpl = require('@tryghost/tpl'); const {NotFoundError, NoPermissionError, BadRequestError, IncorrectUsageError, ValidationError} = require('@tryghost/errors'); const {obfuscatedSetting, isSecretSetting, hideValueIfSecret} = require('./settings-utils'); const logging = require('@tryghost/logging'); const verifyEmailTemplate = require('./emails/verify-email'); const MagicLink = require('../lib/magic-link/MagicLink'); const sentry = require('../../../shared/sentry'); const EMAIL_KEYS = ['members_support_address']; const messages = { problemFindingSetting: 'Problem finding setting: {key}', accessCoreSettingFromExtReq: 'Attempted to access core setting from external request', invalidEmail: 'Invalid email address' }; class SettingsBREADService { /** * * @param {Object} options * @param {Object} options.SettingsModel * @param {Object} options.mail * @param {Object} options.settingsCache - SettingsCache instance * @param {Object} options.singleUseTokenProvider * @param {Object} options.urlUtils * @param {Object} options.labsService - labs service instance * @param {Object} options.limitsService - limits service instance * @param {{service: Object}} options.emailAddressService */ constructor({SettingsModel, settingsCache, labsService, limitsService, mail, singleUseTokenProvider, urlUtils, emailAddressService}) { this.SettingsModel = SettingsModel; this.settingsCache = settingsCache; this.labs = labsService; this.limitsService = limitsService; this.emailAddressService = emailAddressService; /* email verification setup */ this.ghostMailer = new mail.GhostMailer(); const {transporter, getSubject, getText, getHTML, getSigninURL} = { transporter: { sendMail() { // noop - overridden in `sendEmailVerificationMagicLink` } }, getSubject() { // not used - overridden in `sendEmailVerificationMagicLink` return `Verify email address`; }, getText(url, type, email) { return ` Hey there, Please confirm your email address with this link: ${url} For your security, the link will expire in 24 hours time. --- Sent to ${email} If you did not make this request, you can simply delete this message. This email address will not be used. `; }, getHTML(url, type, email) { return verifyEmailTemplate({url, email}); }, getSigninURL(token) { // @todo: need to make this more generic? const adminUrl = urlUtils.urlFor('admin', true); const signinURL = new URL(adminUrl); signinURL.hash = `/settings/portal/edit?verifyEmail=${token}`; return signinURL.href; } }; this.magicLinkService = new MagicLink({ transporter, tokenProvider: singleUseTokenProvider, getSigninURL, getText, getHTML, getSubject, sentry }); } /** * * @param {Object} context ghost API context instance * @returns */ browse(context) { let settings = this.settingsCache.getAll(); return this._formatBrowse(settings, context); } /** * * @param {String} key setting key * @param {Object} [context] API context instance * @returns {Object} an object with a filled out key that comes in a parameter */ read(key, context) { let setting; if (key === 'slack') { const slackURL = this.settingsCache.get('slack_url', {resolve: false}); const slackUsername = this.settingsCache.get('slack_username', {resolve: false}); setting = slackURL || slackUsername; setting.key = 'slack'; setting.value = [{ url: slackURL && slackURL.value, username: slackUsername && slackUsername.value }]; } else { setting = this.settingsCache.get(key, {resolve: false}); } if (!setting) { return Promise.reject(new NotFoundError({ message: tpl(messages.problemFindingSetting, { key: key }) })); } // @TODO: handle in settings model permissible fn if (setting.group === 'core' && !(context && context.internal)) { return Promise.reject(new NoPermissionError({ message: tpl(messages.accessCoreSettingFromExtReq) })); } // NOTE: Labs flags can exist outside of the DB when they are forced on/off // so we grab them from the labs service instead as that's source-of-truth if (setting.key === 'labs') { setting.value = JSON.stringify(this.labs.getAll()); } setting = hideValueIfSecret(setting); return { [key]: setting }; } /** * * @param {Object[]} settings * @param {Object} options * @param {Object} [options.context] * @param {Object|null} [stripeConnectData] * @returns */ async edit(settings, options, stripeConnectData) { let filteredSettings = settings.filter((setting) => { // The `stripe_connect_integration_token` "setting" is only used to set the `stripe_connect_*` settings. return ![ 'stripe_connect_integration_token', 'stripe_connect_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_livemode', 'stripe_connect_account_id', 'stripe_connect_display_name' ].includes(setting.key) // Remove obfuscated settings && !(setting.value === obfuscatedSetting && isSecretSetting(setting)); }); const getSetting = setting => this.settingsCache.get(setting.key, {resolve: false}); const firstUnknownSetting = filteredSettings.find(setting => !getSetting(setting)); if (firstUnknownSetting) { throw new NotFoundError({ message: tpl(messages.problemFindingSetting, { key: firstUnknownSetting.key }) }); } if (!(options.context && options.context.internal)) { const firstCoreSetting = filteredSettings.find(setting => getSetting(setting).group === 'core'); if (firstCoreSetting) { throw new NoPermissionError({ message: tpl(messages.accessCoreSettingFromExtReq) }); } } if (stripeConnectData) { await this.limitsService.errorIfWouldGoOverLimit('limitStripeConnect'); filteredSettings.push({ key: 'stripe_connect_publishable_key', value: stripeConnectData.public_key }); filteredSettings.push({ key: 'stripe_connect_secret_key', value: stripeConnectData.secret_key }); filteredSettings.push({ key: 'stripe_connect_livemode', value: stripeConnectData.livemode }); filteredSettings.push({ key: 'stripe_connect_display_name', value: stripeConnectData.display_name }); filteredSettings.push({ key: 'stripe_connect_account_id', value: stripeConnectData.account_id }); if (stripeConnectData.public_key.match(/pk_live/)) { // Require the Stripe service here as it breaks existing tests otherwise const stripeService = require('../stripe'); // This method currently only triggers a DomainEvent await stripeService.connect(); } } // remove any email properties that are not allowed to be set without verification const {filteredSettings: refilteredSettings, emailsToVerify} = await this.prepSettingsForEmailVerification(filteredSettings, getSetting); const modelArray = await this.SettingsModel.edit(refilteredSettings, options).then((result) => { return this._formatBrowse(_.keyBy(_.invokeMap(result, 'toJSON'), 'key'), options.context); }); return this.respondWithEmailVerification(modelArray, emailsToVerify); } async verifyKeyUpdate(token) { const data = await this.magicLinkService.getDataFromToken(token); const {key, value} = data; // Verify keys (in case they ever change and we have old tokens) if (!EMAIL_KEYS.includes(key)) { throw new IncorrectUsageError({ message: 'Not allowed to update this setting key via tokens' }); } return this.SettingsModel.edit({ key, value }); } /** * * @param {Object} stripeConnectIntegrationToken * @param {Function} getSessionProp sync function fetching property from session store * @param {Function} getStripeConnectTokenData async function retreiving Stripe Connect data for settings * @returns {Promise<Object>} resolves with an object with following keys: public_key, secret_key, livemode, display_name, account_id */ async getStripeConnectData(stripeConnectIntegrationToken, getSessionProp, getStripeConnectTokenData) { if (stripeConnectIntegrationToken && stripeConnectIntegrationToken.value) { try { return await getStripeConnectTokenData(stripeConnectIntegrationToken.value, getSessionProp); } catch (err) { throw new BadRequestError({ err, message: 'The Stripe Connect token could not be parsed.' }); } } } _formatBrowse(inputSettings, context) { let settings = _.values(inputSettings); // CASE: no context passed (functional call) if (!context) { return Promise.resolve(settings.filter((setting) => { return setting.group === 'site'; })); } if (!context.internal) { // CASE: omit core settings unless internal request settings = _.filter(settings, (setting) => { const isCore = setting.group === 'core'; return !isCore; }); // CASE: omit secret settings unless internal request settings = settings.map(hideValueIfSecret); } // NOTE: Labs flags can exist outside of the DB when they are forced on/off // so we grab them from the labs service instead as that's source-of-truth const labsSetting = settings.find(setting => setting.key === 'labs'); if (labsSetting) { labsSetting.value = JSON.stringify(this.labs.getAll()); } return settings; } /** * @private */ async prepSettingsForEmailVerification(settings, getSetting) { const filteredSettings = []; const emailsToVerify = []; for (const setting of settings) { if (EMAIL_KEYS.includes(setting.key)) { const email = setting.value; const key = setting.key; const hasChanged = getSetting(setting).value !== email; if (await this.requiresEmailVerification({email, hasChanged})) { const validated = this.emailAddressService.service.validate(email, 'replyTo'); if (!validated.allowed) { throw new ValidationError({ message: messages.invalidEmail }); } if (validated.verificationEmailRequired) { emailsToVerify.push({email, key}); } else { filteredSettings.push(setting); } } else { filteredSettings.push(setting); } } else { filteredSettings.push(setting); } } return {filteredSettings, emailsToVerify}; } /** * @private */ async requiresEmailVerification({email, hasChanged}) { if (!email || !hasChanged || email === 'noreply') { return false; } // TODO: check for known/verified email return true; } /** * @private */ async respondWithEmailVerification(settings, emailsToVerify) { if (emailsToVerify.length > 0) { for (const {email, key} of emailsToVerify) { await this.sendEmailVerificationMagicLink({email, key}); } settings.meta = settings.meta || {}; settings.meta.sent_email_verification = emailsToVerify.map(v => v.key); } return settings; } /** * @private */ async sendEmailVerificationMagicLink({email, key}) { const fromEmail = this.emailAddressService.service.defaultFromAddress; const {ghostMailer} = this; this.magicLinkService.transporter = { sendMail(message) { if (process.env.NODE_ENV !== 'production') { logging.warn(message.text); } let msg = Object.assign({ from: fromEmail, subject: 'Verify email address', forceTextContent: true }, message); return ghostMailer.send(msg); } }; return this.magicLinkService.sendMagicLink({email, tokenData: {key, value: email}}); } } module.exports = SettingsBREADService;