UNPKG

ghost

Version:

The professional publishing platform

354 lines (295 loc) 12.1 kB
const net = require('net'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const EmailAddressParser = require('../email-address/EmailAddressParser'); const logging = require('@tryghost/logging'); const crypto = require('crypto'); const debug = require('@tryghost/debug')('services:settings-helpers'); const messages = { incorrectKeyType: 'type must be one of "direct" or "connect".' }; class SettingsHelpers { constructor({settingsCache, urlUtils, config, labs, limitService}) { this.settingsCache = settingsCache; this.urlUtils = urlUtils; this.config = config; this.labs = labs; this.limitService = limitService; } isMembersEnabled() { return this.settingsCache.get('members_signup_access') !== 'none'; } isMembersInviteOnly() { return this.settingsCache.get('members_signup_access') === 'invite'; } allowSelfSignup() { return this.settingsCache.get('members_signup_access') === 'all'; } /** * @param {'direct' | 'connect'} type - The "type" of keys to fetch from settings * @returns {{publicKey: string, secretKey: string} | null} */ getStripeKeys(type) { if (type !== 'direct' && type !== 'connect') { throw new errors.IncorrectUsageError({message: tpl(messages.incorrectKeyType)}); } const secretKey = this.settingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}secret_key`); const publicKey = this.settingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}publishable_key`); if (!secretKey || !publicKey) { return null; } return { secretKey, publicKey }; } /** * @returns {{publicKey: string, secretKey: string} | null} */ getActiveStripeKeys() { const stripeDirect = this.config.get('stripeDirect'); if (stripeDirect) { return this.getStripeKeys('direct'); } const connectKeys = this.getStripeKeys('connect'); if (!connectKeys) { return this.getStripeKeys('direct'); } return connectKeys; } isStripeConnected() { return this.getActiveStripeKeys() !== null; } arePaidMembersEnabled() { return this.isMembersEnabled() && this.isStripeConnected(); } getFirstpromoterId() { if (!this.settingsCache.get('firstpromoter')) { return null; } return this.settingsCache.get('firstpromoter_id'); } /** * @deprecated * Please don't make up new email addresses: use the default email addresses */ getDefaultEmailDomain() { if (this.#managedEmailEnabled()) { const customSendingDomain = this.#managedSendingDomain(); if (customSendingDomain) { return customSendingDomain; } } const url = this.urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i')); const domain = (url && url[1]) || ''; if (domain.startsWith('www.')) { return domain.substring('www.'.length); } return domain; } /** * Retrieves the member validation key from the settings cache. The intent is for this key to be used where member * auth is not required. For example, unsubscribe links in emails, which are required to be one-click unsubscribe. * * @returns {string} The member validation key. */ getMembersValidationKey() { return this.settingsCache.get('members_email_auth_secret'); } getMembersSupportAddress() { let supportAddress = this.settingsCache.get('members_support_address'); if (!supportAddress) { // In the new flow, we make a difference between an empty setting (= use default) and a 'noreply' setting (=use noreply @ domain) // Also keep the name of the default email! return EmailAddressParser.stringify(this.getDefaultEmail()); } supportAddress = supportAddress || 'noreply'; // Any fromAddress without domain uses site domain, like default setting `noreply` if (supportAddress.indexOf('@') < 0) { return `${supportAddress}@${this.getDefaultEmailDomain()}`; } return supportAddress; } /** * @deprecated Use getDefaultEmail().address (without name) or EmailAddressParser.stringify(this.getDefaultEmail()) (with name) instead */ getNoReplyAddress() { return this.getDefaultEmailAddress(); } getDefaultEmailAddress() { return this.getDefaultEmail().address; } getDefaultEmail() { // parse the email here and remove the sender name // E.g. when set to "bar" <from@default.com> const configAddress = this.config.get('mail:from'); const parsed = EmailAddressParser.parse(configAddress); if (parsed) { return parsed; } // For missing configs, we default to the old flow logging.warn('Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address'); return { address: this.getLegacyNoReplyAddress() }; } /** * @deprecated * Please start using the new EmailAddressService */ getLegacyNoReplyAddress() { return `noreply@${this.getDefaultEmailDomain()}`; } areDonationsEnabled() { return this.isStripeConnected() && this.config.get('enableTipsAndDonations'); } createUnsubscribeUrl(uuid, options = {}) { const siteUrl = this.urlUtils.urlFor('home', true); const unsubscribeUrl = new URL(siteUrl); const key = this.getMembersValidationKey(); unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/'); if (uuid) { // hash key with member uuid for verification (and to not leak uuid) - it's possible to update member email prefs without logging in // @ts-ignore const hmac = crypto.createHmac('sha256', key).update(`${uuid}`).digest('hex'); unsubscribeUrl.searchParams.set('uuid', uuid); unsubscribeUrl.searchParams.set('key', hmac); } else { unsubscribeUrl.searchParams.set('preview', '1'); } if (options.newsletterUuid) { unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid); } if (options.comments) { unsubscribeUrl.searchParams.set('comments', '1'); } return unsubscribeUrl.href; } /** * Generates an array of the blocked email domains from both config and settings * Normalizes the stored values by trimming, converting to lowercase and keeping only the email domain, e.g. 'hello@spam.xyz' -> 'spam.xyz' * Filters out domains without a dot * Returns an array of unique domains * * @returns {string[]} */ getAllBlockedEmailDomains() { let configBlocklist = this.config.get('spam:blocked_email_domains') || []; let settingsBlocklist = this.settingsCache.get('blocked_email_domains') || []; const normaliseDomains = domain => domain && domain.trim().toLowerCase().split('@').pop(); const filterValidDomains = domain => domain && domain.includes('.'); configBlocklist = Array.isArray(configBlocklist) ? configBlocklist.map(normaliseDomains).filter(filterValidDomains) : []; settingsBlocklist = Array.isArray(settingsBlocklist) ? settingsBlocklist.map(normaliseDomains).filter(filterValidDomains) : []; return Array.from(new Set([ ...configBlocklist, ...settingsBlocklist ])); } /** * Calculated setting for Social web (ActivityPub) * * @returns {boolean} */ isSocialWebEnabled() { // UI setting if (this.settingsCache.get('social_web') !== true) { debug('Social web is disabled in settings'); return false; } // Labs setting if (!this.labs.isSet('ActivityPub')) { debug('Social web is disabled in labs'); return false; } // Ghost (Pro) limits if (this.limitService.isDisabled('limitSocialWeb')) { debug('Social web is not available for Ghost (Pro) sites without a custom domain, or hosted on a subdirectory'); return false; } // Social web (ActivityPub) currently does not support Ghost sites hosted on a subdirectory, e.g. https://example.com/blog/ const subdirectory = this.urlUtils.getSubdir(); if (subdirectory) { debug('Social web is not available for Ghost sites hosted on a subdirectory'); return false; } // Self-hosters cannot connect to production ActivityPub servers from localhost or IPs addresses const siteUrl = new URL(this.urlUtils.getSiteUrl()); const isLocalhost = siteUrl.hostname === 'localhost' || siteUrl.hostname === '127.0.0.1' || siteUrl.hostname === '::1'; const isIP = net.isIP(siteUrl.hostname); if (process.env.NODE_ENV === 'production' && (isLocalhost || isIP)) { debug('Social web is not available from localhost or IPs addresses in production'); return false; } return true; } /** * Calculated setting for Web analytics * * Setting > Labs Flag > Config > Limit Service * * @returns {boolean} */ isWebAnalyticsEnabled() { // UI setting if (this.settingsCache.get('web_analytics') !== true) { debug('Web analytics is disabled in settings'); return false; } // Labs setting if (!this.labs.isSet('trafficAnalytics')) { debug('Web analytics is disabled in labs'); return false; } // Check if web analytics can be configured (limit service and required config) if (!this.isWebAnalyticsConfigured()) { return false; } return true; } /** * Check if web analytics can be configured (used for UI enable/disable state) * * @returns {boolean} */ isWebAnalyticsConfigured() { // Correct config is required if (!this._isValidTinybirdConfig()) { return false; } // Ghost (Pro) limits if (this.limitService.isDisabled('limitAnalytics')) { debug('Web analytics configuration is not available for Ghost (Pro) sites without a custom domain, or hosted on a subdirectory'); return false; } return true; } // PRIVATE /** * Validates tinybird configuration for web analytics * @returns {boolean} True if config is valid, false otherwise * @private */ _isValidTinybirdConfig() { const tinybirdConfig = this.config.get('tinybird'); // First requirement: tinybird:tracker:endpoint is always required if (!tinybirdConfig || !tinybirdConfig.tracker?.endpoint) { debug('Web analytics is not available without tinybird:tracker:endpoint'); return false; } // Second requirement: Either JWT config OR local stats config const hasJwtConfig = !!(tinybirdConfig.workspaceId && tinybirdConfig.adminToken); const hasLocalConfig = !!(tinybirdConfig.stats?.local?.enabled); if (!hasJwtConfig && !hasLocalConfig) { debug('Web analytics requires either (workspaceId + adminToken) or stats.local.enabled'); return false; } return true; } #managedEmailEnabled() { return !!this.config.get('hostSettings:managedEmail:enabled'); } #managedSendingDomain() { return this.config.get('hostSettings:managedEmail:sendingDomain'); } } module.exports = SettingsHelpers;