🐛 Fixed sending emails from email domain that includes www subdomain (#15348)

fixes https://github.com/TryGhost/Team/issues/1855
fixes https://github.com/TryGhost/Team/issues/1866

This commit moves all duplicate methods to get the support email address to a single location. Also methods to get the default email domain are moved.

For the location, I initially wanted to put it at the settings service. But that service doesn't feel like the right place. Instead I created a new settings helpers service. This service takes the settingsCache, urlUtils and config and calculates some special 'calculated' settings based on those:

- Support email methods
- Stripe (active) keys / stripe connected (also removed some duplicate code that calculated the keys in a couple of places)
- All the calculated settings are moved to the settings helpers

I'm not 100% confident in whether this is the right place to put the helpers. Suggestions are welcome.
This commit is contained in:
Simon Backx 2022-09-02 16:57:59 +02:00 committed by GitHub
parent 51ddc39fa7
commit 2e85ae98be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 504 additions and 348 deletions

View File

@ -5,12 +5,13 @@ const htmlToPlaintext = require('@tryghost/html-to-plaintext');
const postEmailSerializer = require('../mega/post-email-serializer');
class CommentsServiceEmails {
constructor({config, logging, models, mailer, settingsCache, urlService, urlUtils}) {
constructor({config, logging, models, mailer, settingsCache, settingsHelpers, urlService, urlUtils}) {
this.config = config;
this.logging = logging;
this.models = models;
this.mailer = mailer;
this.settingsCache = settingsCache;
this.settingsHelpers = settingsHelpers;
this.urlService = urlService;
this.urlUtils = urlUtils;
@ -166,25 +167,8 @@ class CommentsServiceEmails {
return siteDomain;
}
get membersAddress() {
// TODO: get from address of default newsletter?
return `noreply@${this.siteDomain}`;
}
// TODO: duplicated from services/members/config - exrtact to settings?
get supportAddress() {
const supportAddress = this.settingsCache.get('members_support_address') || 'noreply';
// Any fromAddress without domain uses site domain, like default setting `noreply`
if (supportAddress.indexOf('@') < 0) {
return `${supportAddress}@${this.siteDomain}`;
}
return supportAddress;
}
get notificationFromAddress() {
return this.supportAddress || this.membersAddress;
return this.settingsHelpers.getMembersSupportAddress();
}
extractInitials(name = '') {

View File

@ -14,6 +14,7 @@ class CommentsServiceWrapper {
const urlUtils = require('../../../shared/url-utils');
const membersService = require('../members');
const db = require('../../data/db');
const settingsHelpers = require('../settings-helpers');
this.api = new CommentsService({
config,
@ -21,6 +22,7 @@ class CommentsServiceWrapper {
models,
mailer,
settingsCache,
settingsHelpers,
urlService,
urlUtils,
contentGating: membersService.contentGating

View File

@ -15,7 +15,7 @@ const messages = {
};
class CommentsService {
constructor({config, logging, models, mailer, settingsCache, urlService, urlUtils, contentGating}) {
constructor({config, logging, models, mailer, settingsCache, settingsHelpers, urlService, urlUtils, contentGating}) {
/** @private */
this.models = models;
@ -33,6 +33,7 @@ class CommentsService {
models,
mailer,
settingsCache,
settingsHelpers,
urlService,
urlUtils
});

View File

@ -1,101 +1,40 @@
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const tpl = require('@tryghost/tpl');
const {URL} = require('url');
const crypto = require('crypto');
const createKeypair = require('keypair');
const messages = {
incorrectKeyType: 'type must be one of "direct" or "connect".'
};
class MembersConfigProvider {
/**
* @param {object} options
* @param {{get: (key: string) => any}} options.settingsCache
* @param {{get: (key: string) => any}} options.config
* @param {{getDefaultEmailDomain(): string, getMembersSupportAddress(): string, isStripeConnected(): boolean}} options.settingsHelpers
* @param {any} options.urlUtils
*/
constructor(options) {
this._settingsCache = options.settingsCache;
this._config = options.config;
this._urlUtils = options.urlUtils;
constructor({settingsCache, settingsHelpers, urlUtils}) {
this._settingsCache = settingsCache;
this._settingsHelpers = settingsHelpers;
this._urlUtils = urlUtils;
}
/**
* @private
*/
_getDomain() {
const url = this._urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
const domain = (url && url[1]) || '';
if (domain.startsWith('www.')) {
return domain.replace(/^(www)\.(?=[^/]*\..{2,5})/, '');
}
return domain;
get defaultEmailDomain() {
return this._settingsHelpers.getDefaultEmailDomain();
}
getEmailFromAddress() {
// Individual from addresses are set per newsletter - this is the fallback address
return `noreply@${this._getDomain()}`;
return `noreply@${this.defaultEmailDomain}`;
}
getEmailSupportAddress() {
const supportAddress = this._settingsCache.get('members_support_address') || 'noreply';
// Any fromAddress without domain uses site domain, like default setting `noreply`
if (supportAddress.indexOf('@') < 0) {
return `${supportAddress}@${this._getDomain()}`;
}
return supportAddress;
return this._settingsHelpers.getMembersSupportAddress();
}
getAuthEmailFromAddress() {
return this.getEmailSupportAddress() || this.getEmailFromAddress();
}
/**
* @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;
return this.getEmailSupportAddress();
}
isStripeConnected() {
return this.getActiveStripeKeys() !== null;
return this._settingsHelpers.isStripeConnected();
}
getAuthSecret() {

View File

@ -20,6 +20,7 @@ const VerificationTrigger = require('@tryghost/verification-trigger');
const DomainEvents = require('@tryghost/domain-events');
const {LastSeenAtUpdater} = require('@tryghost/members-events-service');
const DatabaseInfo = require('@tryghost/database-info');
const settingsHelpers = require('../settings-helpers');
const messages = {
noLiveKeysInDevelopment: 'Cannot use live stripe keys in development. Please restart in production mode.',
@ -30,7 +31,7 @@ const messages = {
const ghostMailer = new GhostMailer();
const membersConfig = new MembersConfigProvider({
config,
settingsHelpers,
settingsCache,
urlUtils
});

View File

@ -0,0 +1,6 @@
const settingsCache = require('../../../shared/settings-cache');
const urlUtils = require('../../../shared/url-utils');
const config = require('../../../shared/config');
const SettingsHelpers = require('./settings-helpers');
module.exports = new SettingsHelpers({settingsCache, urlUtils, config});

View File

@ -0,0 +1,99 @@
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
incorrectKeyType: 'type must be one of "direct" or "connect".'
};
class SettingsHelpers {
constructor({settingsCache, urlUtils, config}) {
this.settingsCache = settingsCache;
this.urlUtils = urlUtils;
this.config = config;
}
isMembersEnabled() {
return this.settingsCache.get('members_signup_access') !== 'none';
}
isMembersInviteOnly() {
return this.settingsCache.get('members_signup_access') === 'invite';
}
/**
* @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');
}
getDefaultEmailDomain() {
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;
}
getMembersSupportAddress() {
const supportAddress = this.settingsCache.get('members_support_address') || 'noreply';
// Any fromAddress without domain uses site domain, like default setting `noreply`
if (supportAddress.indexOf('@') < 0) {
return `${supportAddress}@${this.getDefaultEmailDomain()}`;
}
return supportAddress;
}
}
module.exports = SettingsHelpers;

View File

@ -2,13 +2,9 @@
* Settings Lib
* A collection of utilities for handling settings including a cache
*/
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const events = require('../../lib/common/events');
const models = require('../../models');
const labs = require('../../../shared/labs');
const config = require('../../../shared/config');
const adapterManager = require('../adapter-manager');
const SettingsCache = require('../../../shared/settings-cache');
const SettingsBREADService = require('./settings-bread-service');
@ -18,10 +14,7 @@ const SingleUseTokenProvider = require('../members/SingleUseTokenProvider');
const urlUtils = require('../../../shared/url-utils');
const ObjectId = require('bson-objectid');
const messages = {
incorrectKeyType: 'type must be one of "direct" or "connect".'
};
const settingsHelpers = require('../settings-helpers');
const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
@ -79,76 +72,16 @@ module.exports = {
SettingsCache.reset(events);
},
isMembersEnabled() {
return SettingsCache.get('members_signup_access') !== 'none';
},
isMembersInviteOnly() {
return SettingsCache.get('members_signup_access') === 'invite';
},
/**
* @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 = SettingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}secret_key`);
const publicKey = 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 = config.get('stripeDirect');
if (stripeDirect) {
return this.getStripeKeys('direct');
}
const connectKeys = this.getStripeKeys('connect');
if (!connectKeys) {
return this.getStripeKeys('direct');
}
return connectKeys;
},
arePaidMembersEnabled() {
return this.isMembersEnabled() && this.getActiveStripeKeys() !== null;
},
getFirstpromoterId() {
if (!SettingsCache.get('firstpromoter')) {
return null;
}
return SettingsCache.get('firstpromoter_id');
},
/**
*
*/
getCalculatedFields() {
const fields = [];
fields.push(new CalculatedField({key: 'members_enabled', type: 'boolean', group: 'members', fn: this.isMembersEnabled.bind(this), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'members_invite_only', type: 'boolean', group: 'members', fn: this.isMembersInviteOnly.bind(this), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'paid_members_enabled', type: 'boolean', group: 'members', fn: this.arePaidMembersEnabled.bind(this), dependents: ['members_signup_access', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']}));
fields.push(new CalculatedField({key: 'firstpromoter_account', type: 'string', group: 'firstpromoter', fn: this.getFirstpromoterId.bind(this), dependents: ['firstpromoter', 'firstpromoter_id']}));
fields.push(new CalculatedField({key: 'members_enabled', type: 'boolean', group: 'members', fn: settingsHelpers.isMembersEnabled.bind(settingsHelpers), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'members_invite_only', type: 'boolean', group: 'members', fn: settingsHelpers.isMembersInviteOnly.bind(settingsHelpers), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'paid_members_enabled', type: 'boolean', group: 'members', fn: settingsHelpers.arePaidMembersEnabled.bind(settingsHelpers), dependents: ['members_signup_access', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']}));
fields.push(new CalculatedField({key: 'firstpromoter_account', type: 'string', group: 'firstpromoter', fn: settingsHelpers.getFirstpromoterId.bind(settingsHelpers), dependents: ['firstpromoter', 'firstpromoter_id']}));
return fields;
},

View File

@ -2,22 +2,20 @@ class StaffServiceWrapper {
init() {
const StaffService = require('@tryghost/staff-service');
const config = require('../../../shared/config');
const logging = require('@tryghost/logging');
const models = require('../../models');
const {GhostMailer} = require('../mail');
const mailer = new GhostMailer();
const settingsCache = require('../../../shared/settings-cache');
const urlService = require('../url');
const urlUtils = require('../../../shared/url-utils');
const settingsHelpers = require('../settings-helpers');
this.api = new StaffService({
config,
logging,
models,
mailer,
settingsHelpers,
settingsCache,
urlService,
urlUtils
});
}

View File

@ -16,7 +16,7 @@ const messages = {
*/
module.exports = {
getConfig(settings, config, urlUtils) {
getConfig({config, urlUtils, settingsHelpers}) {
/**
* @returns {StripeURLConfig}
*/
@ -41,43 +41,7 @@ module.exports = {
};
}
/**
* @param {'direct' | 'connect'} type - The "type" of keys to fetch from settings
* @returns {{publicKey: string, secretKey: string} | null}
*/
function getStripeKeys(type) {
const secretKey = settings.get(`stripe_${type === 'connect' ? 'connect_' : ''}secret_key`);
const publicKey = settings.get(`stripe_${type === 'connect' ? 'connect_' : ''}publishable_key`);
if (!secretKey || !publicKey) {
return null;
}
return {
secretKey,
publicKey
};
}
/**
* @returns {{publicKey: string, secretKey: string} | null}
*/
function getActiveStripeKeys() {
const stripeDirect = config.get('stripeDirect');
if (stripeDirect) {
return getStripeKeys('direct');
}
const connectKeys = getStripeKeys('connect');
if (!connectKeys) {
return getStripeKeys('direct');
}
return connectKeys;
}
const keys = getActiveStripeKeys();
const keys = settingsHelpers.getActiveStripeKeys();
if (!keys) {
return null;
}

View File

@ -8,9 +8,10 @@ const urlUtils = require('../../../shared/url-utils');
const events = require('../../lib/common/events');
const models = require('../../models');
const {getConfig} = require('./config');
const settingsHelpers = require('../settings-helpers');
async function configureApi() {
const cfg = getConfig(settings, config, urlUtils);
const cfg = getConfig({settingsHelpers, config, urlUtils});
if (cfg) {
cfg.testEnv = process.env.NODE_ENV.startsWith('test');
await module.exports.configure(cfg);

View File

@ -1854,7 +1854,7 @@ Object {
exports[`Comments API when commenting enabled for all when authenticated Can fetch counts 1: [body] 1`] = `
Object {
"618ba1ffbe2896088840a6df": 13,
"618ba1ffbe2896088840a6df": 15,
"618ba1ffbe2896088840a6e1": 0,
"618ba1ffbe2896088840a6e3": 0,
}
@ -2270,6 +2270,86 @@ Object {
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can reply to a comment with custom support email 1: [body] 1`] = `
Object {
"comments": Array [
Object {
"count": Object {
"likes": Any<Number>,
"replies": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "This is a reply",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"bio": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"replies": Array [],
"status": "published",
},
],
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can reply to a comment with custom support email 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "342",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/comments\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can reply to a comment with www domain 1: [body] 1`] = `
Object {
"comments": Array [
Object {
"count": Object {
"likes": Any<Number>,
"replies": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "This is a reply",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"bio": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"replies": Array [],
"status": "published",
},
],
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can reply to a comment with www domain 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "342",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/comments\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can reply to your own comment 1: [body] 1`] = `
Object {
"comments": Array [
@ -2319,6 +2399,53 @@ Object {
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can request last page of replies 1: [body] 1`] = `
Object {
"comments": Array [
Object {
"count": Object {
"likes": Any<Number>,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "This is a reply",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"bio": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"status": "published",
},
],
"meta": Object {
"pagination": Object {
"limit": 3,
"next": null,
"page": 3,
"pages": 3,
"prev": 2,
"total": 7,
},
},
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can request last page of replies 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "401",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Comments API when commenting enabled for all when authenticated Can request second page of replies 1: [body] 1`] = `
Object {
"comments": Array [
@ -2477,6 +2604,42 @@ Object {
},
"status": "published",
},
Object {
"count": Object {
"likes": Any<Number>,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "This is a reply",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"bio": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"status": "published",
},
Object {
"count": Object {
"likes": Any<Number>,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "This is a reply",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"bio": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": null,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"status": "published",
},
],
"meta": Object {
"pagination": Object {
@ -2485,7 +2648,7 @@ Object {
"page": 1,
"pages": 1,
"prev": null,
"total": 5,
"total": 7,
},
},
}
@ -2495,7 +2658,7 @@ exports[`Comments API when commenting enabled for all when authenticated Can ret
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1629",
"content-length": "2235",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",

View File

@ -1,11 +1,12 @@
const assert = require('assert');
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = require('../../utils/e2e-framework');
const {anyEtag, anyObjectId, anyLocationFor, anyISODateTime, anyErrorId, anyUuid, anyNumber, anyBoolean} = matchers;
const should = require('should');
const models = require('../../../core/server/models');
const moment = require('moment-timezone');
const settingsCache = require('../../../core/shared/settings-cache');
const sinon = require('sinon');
const settingsService = require('../../../core/server/services/settings/settings-service');
let membersAgent, membersAgent2, postId, postTitle, commentId;
@ -98,7 +99,7 @@ async function testCanCommentOnPost(member) {
should.notEqual(member.get('last_commented_at'), null, 'The member should have a `last_commented_at` property after posting a comment.');
}
async function testCanReply(member) {
async function testCanReply(member, emailMatchers = {}) {
const date = new Date(0);
await models.Member.edit({last_seen_at: date, last_commented_at: date}, {id: member.get('id')});
@ -125,6 +126,7 @@ async function testCanReply(member) {
});
mockManager.assert.sentEmail({
...emailMatchers,
subject: '↪️ New reply to your comment on Ghost',
to: fixtureManager.get('members', 0).email
});
@ -195,6 +197,7 @@ describe('Comments API', function () {
});
afterEach(function () {
configUtils.restore();
mockManager.restore();
});
@ -210,7 +213,7 @@ describe('Comments API', function () {
});
});
after(async function () {
afterEach(async function () {
sinon.restore();
});
@ -245,13 +248,15 @@ describe('Comments API', function () {
});
describe('when authenticated', function () {
let getStub;
before(async function () {
await membersAgent.loginAs('member@example.com');
member = await models.Member.findOne({email: 'member@example.com'}, {require: true});
await membersAgent2.loginAs('member2@example.com');
});
beforeEach(function () {
const getStub = sinon.stub(settingsCache, 'get');
getStub = sinon.stub(settingsCache, 'get');
getStub.callsFake((key, options) => {
if (key === 'comments_enabled') {
return 'all';
@ -323,7 +328,7 @@ describe('Comments API', function () {
it('Can reply to a comment', async function () {
await testCanReply(member);
});
let testReplyId;
it('Limits returned replies to 3', async function () {
const parentId = fixtureManager.get('comments', 0).id;
@ -378,6 +383,26 @@ describe('Comments API', function () {
body.comments[0].count.replies.should.eql(5);
});
});
it('Can reply to a comment with www domain', async function () {
// Test that the www. is stripped from the default
configUtils.set('url', 'http://www.domain.example/');
await testCanReply(member, {from: 'noreply@domain.example'});
});
it('Can reply to a comment with custom support email', async function () {
// Test that the www. is stripped from the default
getStub.callsFake((key, options) => {
if (key === 'members_support_address') {
return 'support@example.com';
}
if (key === 'comments_enabled') {
return 'all';
}
return getStub.wrappedMethod.call(settingsCache, key, options);
});
await testCanReply(member, {from: 'support@example.com'});
});
it('Can like a comment', async function () {
// Check not liked
@ -472,11 +497,11 @@ describe('Comments API', function () {
etag: anyEtag
})
.matchBodySnapshot({
comments: new Array(5).fill(commentMatcher)
comments: new Array(7).fill(commentMatcher)
})
.expect(({body}) => {
should(body.comments[0].count.replies).be.undefined();
should(body.meta.pagination.total).eql(5);
should(body.meta.pagination.total).eql(7);
should(body.meta.pagination.next).eql(null);
// Check liked + likes working for replies too
@ -486,22 +511,22 @@ describe('Comments API', function () {
});
});
it('Can request second page of replies', async function () {
it('Can request last page of replies', async function () {
const parentId = fixtureManager.get('comments', 0).id;
// Check initial status: two replies before test
await membersAgent
.get(`/api/comments/${parentId}/replies/?page=2&limit=3`)
.get(`/api/comments/${parentId}/replies/?page=3&limit=3`)
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
comments: new Array(2).fill(commentMatcher)
comments: new Array(1).fill(commentMatcher)
})
.expect(({body}) => {
should(body.comments[0].count.replies).be.undefined();
should(body.meta.pagination.total).eql(5);
should(body.meta.pagination.total).eql(7);
should(body.meta.pagination.next).eql(null);
});
});

View File

@ -0,0 +1,93 @@
const should = require('should');
const sinon = require('sinon');
const configUtils = require('../../../../utils/configUtils');
const SettingsHelpers = require('../../../../../core/server/services/settings-helpers/settings-helpers');
function createSettingsMock({setDirect, setConnect}) {
const getStub = sinon.stub();
getStub.withArgs('members_signup_access').returns('all');
getStub.withArgs('stripe_secret_key').returns(setDirect ? 'direct_secret' : null);
getStub.withArgs('stripe_publishable_key').returns(setDirect ? 'direct_publishable' : null);
getStub.withArgs('stripe_plans').returns([{
name: 'Monthly',
currency: 'usd',
interval: 'month',
amount: 1000
}, {
name: 'Yearly',
currency: 'usd',
interval: 'year',
amount: 10000
}]);
getStub.withArgs('stripe_connect_secret_key').returns(setConnect ? 'connect_secret' : null);
getStub.withArgs('stripe_connect_publishable_key').returns(setConnect ? 'connect_publishable' : null);
getStub.withArgs('stripe_connect_livemode').returns(true);
getStub.withArgs('stripe_connect_display_name').returns('Test');
getStub.withArgs('stripe_connect_account_id').returns('ac_XXXXXXXXXXXXX');
return {
get: getStub
};
}
describe('Settings Helpers - getActiveStripeKeys', function () {
beforeEach(function () {
configUtils.set({
url: 'http://domain.tld/subdir',
admin: {url: 'http://sub.domain.tld'}
});
});
afterEach(function () {
configUtils.restore();
});
it('Uses direct keys when stripeDirect is true, regardles of which keys exist', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: true});
configUtils.set({
stripeDirect: true
});
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils: {}});
const keys = settingsHelpers.getActiveStripeKeys();
should.equal(keys.publicKey, 'direct_publishable');
should.equal(keys.secretKey, 'direct_secret');
});
it('Does not use connect keys if stripeDirect is true, and the direct keys do not exist', function () {
const fakeSettings = createSettingsMock({setDirect: false, setConnect: true});
configUtils.set({
stripeDirect: true
});
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils: {}});
const keys = settingsHelpers.getActiveStripeKeys();
should.equal(keys, null);
});
it('Uses connect keys when stripeDirect is false, and the connect keys exist', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: true});
configUtils.set({
stripeDirect: false
});
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils: {}});
const keys = settingsHelpers.getActiveStripeKeys();
should.equal(keys.publicKey, 'connect_publishable');
should.equal(keys.secretKey, 'connect_secret');
});
it('Uses direct keys when stripeDirect is false, but the connect keys do not exist', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: false});
configUtils.set({
stripeDirect: false
});
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils: {}});
const keys = settingsHelpers.getActiveStripeKeys();
should.equal(keys.publicKey, 'direct_publishable');
should.equal(keys.secretKey, 'direct_secret');
});
});

View File

@ -6,37 +6,12 @@ const configUtils = require('../../../../utils/configUtils');
const {getConfig} = require('../../../../../core/server/services/stripe/config');
/**
* @param {object} options
* @param {boolean} options.setDirect - Whether the "direct" keys should be set
* @param {boolean} options.setConnect - Whether the connect_integration keys should be set
*/
function createSettingsMock({setDirect, setConnect}) {
const getStub = sinon.stub();
getStub.withArgs('members_signup_access').returns('all');
getStub.withArgs('stripe_secret_key').returns(setDirect ? 'direct_secret' : null);
getStub.withArgs('stripe_publishable_key').returns(setDirect ? 'direct_publishable' : null);
getStub.withArgs('stripe_plans').returns([{
name: 'Monthly',
currency: 'usd',
interval: 'month',
amount: 1000
}, {
name: 'Yearly',
currency: 'usd',
interval: 'year',
amount: 10000
}]);
getStub.withArgs('stripe_connect_secret_key').returns(setConnect ? 'connect_secret' : null);
getStub.withArgs('stripe_connect_publishable_key').returns(setConnect ? 'connect_publishable' : null);
getStub.withArgs('stripe_connect_livemode').returns(true);
getStub.withArgs('stripe_connect_display_name').returns('Test');
getStub.withArgs('stripe_connect_account_id').returns('ac_XXXXXXXXXXXXX');
function createSettingsHelpersMock() {
return {
get: getStub
getActiveStripeKeys: sinon.stub().returns({
secretKey: 'direct_secret',
publicKey: 'direct_publishable'
})
};
}
@ -71,67 +46,35 @@ describe('Stripe - config', function () {
configUtils.restore();
});
it('Uses direct keys when stripeDirect is true, regardles of which keys exist', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: true});
configUtils.set({
stripeDirect: true
});
const fakeUrlUtils = createUrlUtilsMock();
const config = getConfig(fakeSettings, configUtils.config, fakeUrlUtils);
should.equal(config.publicKey, 'direct_publishable');
should.equal(config.secretKey, 'direct_secret');
});
it('Does not use connect keys if stripeDirect is true, and the direct keys do not exist', function () {
const fakeSettings = createSettingsMock({setDirect: false, setConnect: true});
configUtils.set({
stripeDirect: true
});
const fakeUrlUtils = createUrlUtilsMock();
const config = getConfig(fakeSettings, configUtils.config, fakeUrlUtils);
should.equal(config, null);
});
it('Uses connect keys when stripeDirect is false, and the connect keys exist', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: true});
configUtils.set({
stripeDirect: false
});
const fakeUrlUtils = createUrlUtilsMock();
const config = getConfig(fakeSettings, configUtils.config, fakeUrlUtils);
should.equal(config.publicKey, 'connect_publishable');
should.equal(config.secretKey, 'connect_secret');
});
it('Uses direct keys when stripeDirect is false, but the connect keys do not exist', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: false});
configUtils.set({
stripeDirect: false
});
const fakeUrlUtils = createUrlUtilsMock();
const config = getConfig(fakeSettings, configUtils.config, fakeUrlUtils);
should.equal(config.publicKey, 'direct_publishable');
should.equal(config.secretKey, 'direct_secret');
});
it('Includes the subdirectory in the webhookHandlerUrl', function () {
it('Returns null if Stripe not connected', function () {
configUtils.set({
stripeDirect: false,
url: 'http://site.com/subdir'
});
const fakeSettings = createSettingsMock({setDirect: true, setConnect: false});
const settingsHelpers = {
getActiveStripeKeys: sinon.stub().returns(null)
};
const config = getConfig({settingsHelpers, config: configUtils.config, urlUtils: {}});
should.equal(config, null);
});
it('Includes the subdirectory in the webhookHandlerUrl', function () {
configUtils.set({
url: 'http://site.com/subdir'
});
const settingsHelpers = createSettingsHelpersMock();
const fakeUrlUtils = createUrlUtilsMock();
const config = getConfig(fakeSettings, configUtils.config, fakeUrlUtils);
const config = getConfig({settingsHelpers, config: configUtils.config, urlUtils: fakeUrlUtils});
should.equal(config.secretKey, 'direct_secret');
should.equal(config.publicKey, 'direct_publishable');
should.equal(config.webhookHandlerUrl, 'http://site.com/subdir/members/webhooks/stripe/');
should.exist(config.checkoutSessionSuccessUrl);
should.exist(config.checkoutSessionCancelUrl);
should.exist(config.checkoutSetupSessionSuccessUrl);
should.exist(config.checkoutSetupSessionCancelUrl);
});
});

View File

@ -75,6 +75,8 @@ const sentEmail = (matchers) => {
let spyCall = mocks.mail.getCall(emailCount);
assert.notEqual(spyCall, null, 'Expected at least ' + (emailCount + 1) + ' emails sent.');
// We increment here so that the messaging has an index of 1, whilst getting the call has an index of 0
emailCount += 1;

View File

@ -4,10 +4,11 @@ const _ = require('lodash');
const moment = require('moment');
class StaffServiceEmails {
constructor({logging, models, mailer, settingsCache, urlUtils}) {
constructor({logging, models, mailer, settingsHelpers, settingsCache, urlUtils}) {
this.logging = logging;
this.models = models;
this.mailer = mailer;
this.settingsHelpers = settingsHelpers;
this.settingsCache = settingsCache;
this.urlUtils = urlUtils;
@ -248,29 +249,17 @@ class StaffServiceEmails {
return siteDomain;
}
get defaultEmailDomain() {
return this.settingsHelpers.getDefaultEmailDomain();
}
get membersAddress() {
// TODO: get from address of default newsletter?
return `noreply@${this.siteDomain}`;
return `noreply@${this.defaultEmailDomain}`;
}
get fromEmailAddress() {
return `ghost@${this.siteDomain}`;
}
// TODO: duplicated from services/members/config - exrtact to settings?
get supportAddress() {
const supportAddress = this.settingsCache.get('members_support_address') || 'noreply';
// Any fromAddress without domain uses site domain, like default setting `noreply`
if (supportAddress.indexOf('@') < 0) {
return `${supportAddress}@${this.siteDomain}`;
}
return supportAddress;
}
get notificationFromAddress() {
return this.supportAddress || this.membersAddress;
return `ghost@${this.defaultEmailDomain}`;
}
extractInitials(name = '') {

View File

@ -1,5 +1,5 @@
class StaffService {
constructor({logging, models, mailer, settingsCache, urlUtils}) {
constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils}) {
this.logging = logging;
/** @private */
@ -13,6 +13,7 @@ class StaffService {
logging,
models,
mailer,
settingsHelpers,
settingsCache,
urlUtils
});

View File

@ -113,6 +113,36 @@ describe('StaffService', function () {
forUpdate: true
};
let stubs;
const settingsCache = {
get: (setting) => {
if (setting === 'title') {
return 'Ghost Site';
} else if (setting === 'accent_color') {
return '#ffffff';
}
return '';
}
};
const urlUtils = {
getSiteUrl: () => {
return 'https://ghost.example';
},
urlJoin: (adminUrl,hash,path) => {
return `${adminUrl}/${hash}${path}`;
},
urlFor: () => {
return 'https://admin.ghost.example';
}
};
const settingsHelpers = {
getDefaultEmailDomain: () => {
return 'ghost.example';
}
};
beforeEach(function () {
mailStub = sinon.stub().resolves();
getEmailAlertUsersStub = sinon.stub().resolves([{
@ -132,27 +162,9 @@ describe('StaffService', function () {
mailer: {
send: mailStub
},
settingsCache: {
get: (setting) => {
if (setting === 'title') {
return 'Ghost Site';
} else if (setting === 'accent_color') {
return '#ffffff';
}
return '';
}
},
urlUtils: {
getSiteUrl: () => {
return 'https://ghost.example';
},
urlJoin: (adminUrl,hash,path) => {
return `${adminUrl}/${hash}${path}`;
},
urlFor: () => {
return 'https://admin.ghost.example';
}
}
settingsCache,
urlUtils,
settingsHelpers
});
stubs = {mailStub, getEmailAlertUsersStub};
});